-
Notifications
You must be signed in to change notification settings - Fork 27
Story 2436: Mailing List Subscription Post Auth #2475
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
base: develop
Are you sure you want to change the base?
Changes from all commits
3061f4d
c5df2c7
90b84b1
95b37a3
15c69ba
1246da3
6fcd20d
bf23387
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,9 +7,9 @@ | |
| 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.db import IntegrityError, transaction | ||
| from django.http import HttpResponse, HttpResponseRedirect | ||
| from django.shortcuts import render | ||
| from django.template.loader import render_to_string | ||
| from django.urls import reverse | ||
|
|
@@ -83,6 +83,43 @@ def _is_rate_limited(request) -> bool: | |
| return cache.incr(key) > _SUBSCRIBE_RATE_LIMIT | ||
|
|
||
|
|
||
| def _subscribe_pending( | ||
| request, user, email: str, list_ids: list[str] | ||
| ) -> tuple[list[str], str | None]: | ||
| """Create PENDING subscription records and send a confirmation email. | ||
|
|
||
| Returns (succeeded, error_message). On email failure the records are | ||
| rolled back and error_message is set; on partial IntegrityError the | ||
| affected list is silently skipped. | ||
| """ | ||
| succeeded = [] | ||
| for lid in list_ids: | ||
| try: | ||
| with transaction.atomic(): | ||
| UserMailingListSubscription.objects.update_or_create( | ||
| user=user, | ||
| list_id=lid, | ||
| defaults={"email": email, "status": SubscriptionStatus.PENDING}, | ||
| ) | ||
| succeeded.append(lid) | ||
| except IntegrityError: | ||
| pass | ||
|
|
||
| if not succeeded: | ||
| return [], None | ||
|
|
||
| try: | ||
| _send_confirmation_email(request, email, user.pk, succeeded) | ||
| except Exception as exc: | ||
| logger.error("Failed to send confirmation email to %s: %s", email, exc) | ||
| UserMailingListSubscription.objects.filter( | ||
| user=user, list_id__in=succeeded | ||
| ).delete() | ||
| return [], "Could not send confirmation email. Please try again." | ||
|
|
||
| return succeeded, None | ||
|
|
||
|
|
||
| def _send_confirmation_email( | ||
| request, email: str, user_id: int | None, list_ids: list[str] | ||
| ) -> None: | ||
|
|
@@ -520,9 +557,27 @@ class ModalSubscribeView(View): | |
| any currently tracked lists that were unchecked. | ||
| Anonymous flow: subscribe-only - sends a single confirmation email for all | ||
| checked lists. Unsubscribe not supported for anonymous users. | ||
|
|
||
| With JS the form posts via HTMX and the card partial is swapped in place. | ||
| Without JS the form posts natively; we redirect (PRG) back to the page so | ||
| the card re-renders from DB state on the next GET. | ||
| """ | ||
|
|
||
| def _card(self, request, **ctx): | ||
| if not _is_htmx(request): | ||
| state = ctx.get("state") | ||
| if state == "error": | ||
| return _prg_redirect( | ||
| request, | ||
| ml_state="error", | ||
| ml_error=ctx.get("error_message", ""), | ||
| ml_email=ctx.get("user_email", ""), | ||
| ) | ||
| if state == "pending" and not request.user.is_authenticated: | ||
| return _prg_redirect( | ||
| request, ml_state="pending", ml_email=ctx.get("user_email", "") | ||
| ) | ||
| return _prg_redirect(request) | ||
| return render( | ||
| request, | ||
| "v3/includes/_mailing_list_card.html", | ||
|
|
@@ -594,38 +649,20 @@ def _handle_authenticated(self, request, email, list_ids, managed_lists): | |
| manage_url=manage_url, | ||
| ) | ||
|
|
||
| succeeded = [] | ||
| for lid in to_subscribe: | ||
| try: | ||
| with transaction.atomic(): | ||
| UserMailingListSubscription.objects.update_or_create( | ||
| user=request.user, | ||
| list_id=lid, | ||
| defaults={"email": email, "status": SubscriptionStatus.PENDING}, | ||
| ) | ||
| succeeded.append(lid) | ||
| except IntegrityError: | ||
| pass | ||
| succeeded, error = _subscribe_pending( | ||
| request, request.user, email, to_subscribe | ||
| ) | ||
|
|
||
| if not succeeded: | ||
| if error: | ||
| return self._card( | ||
| request, | ||
| state="error", | ||
| error_message="Could not subscribe. Please try again.", | ||
| user_email=email, | ||
| request, state="error", error_message=error, user_email=email | ||
| ) | ||
|
|
||
| try: | ||
| _send_confirmation_email(request, email, request.user.pk, succeeded) | ||
| 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=succeeded | ||
| ).delete() | ||
| if not succeeded: | ||
| return self._card( | ||
| request, | ||
| state="error", | ||
| error_message="Could not send confirmation email. Please try again.", | ||
| error_message="Could not subscribe. Please try again.", | ||
| user_email=email, | ||
| ) | ||
|
|
||
|
|
@@ -657,3 +694,33 @@ def _handle_anonymous(self, request, email, list_ids): | |
| ) | ||
|
|
||
| return self._card(request, state="pending", user_email=email) | ||
|
|
||
|
|
||
| class PostAuthSubscribeView(LoginRequiredMixin, View): | ||
| """Subscribe to one or more lists from the post-login homepage modal. | ||
|
|
||
| Only for authenticated users. Returns an empty fragment so HTMX removes | ||
| the modal from the DOM. Falls back to a homepage redirect for non-HTMX. | ||
| """ | ||
|
|
||
| def post(self, request): | ||
| email = (request.POST.get("email") or "").strip() or request.user.email | ||
| managed_lists = set(constants.MAILMAN_LISTS) | ||
| list_ids = [ | ||
| lid for lid in request.POST.getlist("list_id") if lid in managed_lists | ||
| ] | ||
|
|
||
| if list_ids and not _is_rate_limited(request): | ||
| current = { | ||
| sub.list_id | ||
| for sub in UserMailingListSubscription.objects.filter( | ||
| user=request.user, list_id__in=managed_lists | ||
| ) | ||
| } | ||
| to_subscribe = [lid for lid in list_ids if lid not in current] | ||
|
|
||
| _subscribe_pending(request, request.user, email, to_subscribe) | ||
|
|
||
| if _is_htmx(request): | ||
| return HttpResponse("") | ||
|
Comment on lines
+713
to
+725
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Don’t close the modal on failed subscriptions. This branch swallows both rate-limit failures and 🧰 Tools🪛 ast-grep (0.44.0)[error] 706-706: Lack of sanitization of user data (http-response-from-request) 🤖 Prompt for AI Agents |
||
| return _prg_redirect(request) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -203,3 +203,124 @@ input.mailing-list-modal__checkbox:focus-visible + .mailing-list-modal__checkbox | |
| line-height: var(--line-height-default); | ||
| color: var(--color-text-secondary); | ||
| } | ||
|
|
||
| /* ============================================ | ||
| POST-AUTH MODAL — first-login subscription prompt | ||
| ============================================ */ | ||
|
|
||
| /* Header contains only the title (no close X per design) */ | ||
| .ml-post-auth-modal__header { | ||
| padding: var(--space-large) var(--space-large) 0 var(--space-large); | ||
| width: 100%; | ||
| box-sizing: border-box; | ||
| } | ||
|
|
||
| .ml-post-auth-modal__header .dialog-modal__title { | ||
| margin: 0; | ||
| } | ||
|
|
||
| /* Email input row */ | ||
| .ml-post-auth-modal__email-row { | ||
| padding: 0 var(--space-large); | ||
| width: 100%; | ||
| box-sizing: border-box; | ||
| } | ||
|
|
||
| .ml-post-auth-modal__email-field { | ||
| display: block; | ||
| width: 100%; | ||
| height: 40px; | ||
| padding: 0 var(--space-large); | ||
| box-sizing: border-box; | ||
| background-color: var(--color-surface-weak) !important; | ||
| border: 1px solid var(--color-stroke-weak) !important; | ||
| border-radius: var(--border-radius-xl) !important; | ||
| font-family: var(--font-sans); | ||
| font-size: var(--font-size-base); | ||
| font-weight: var(--font-weight-regular); | ||
| line-height: var(--line-height-default); | ||
| letter-spacing: var(--letter-spacing-tight); | ||
| color: var(--color-text-secondary); | ||
| outline: none; | ||
| box-shadow: none; | ||
| -webkit-appearance: none; | ||
| appearance: none; | ||
| } | ||
|
|
||
| .ml-post-auth-modal__email-field::placeholder { | ||
| color: var(--color-text-secondary); | ||
| } | ||
|
|
||
| .ml-post-auth-modal__email-field:focus-visible { | ||
| background-color: var(--color-surface-mid); | ||
| border-color: var(--color-stroke-strong); | ||
| } | ||
|
|
||
| .ml-post-auth-modal__email-field--error { | ||
| background-color: var(--color-surface-error-weak) !important; | ||
| border-color: var(--color-stroke-error) !important; | ||
| color: var(--color-text-error); | ||
| } | ||
|
Comment on lines
+254
to
+263
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Invalid email state hides the keyboard focus indicator.
🤖 Prompt for AI Agents |
||
|
|
||
| .ml-post-auth-modal__email-row .field__error { | ||
| margin-top: var(--space-s); | ||
| } | ||
|
|
||
| .ml-post-auth-modal__container .dialog-modal__buttons { | ||
| padding-top: var(--space-large); | ||
| } | ||
|
|
||
| /* Description + list card section */ | ||
| .ml-post-auth-modal__body { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: var(--space-default); | ||
| width: 100%; | ||
| box-sizing: border-box; | ||
| } | ||
|
|
||
| .ml-post-auth-modal__description { | ||
| padding: 0 var(--space-large); | ||
| margin: 0; | ||
| font-family: var(--font-sans); | ||
| font-size: var(--font-size-base); | ||
| font-weight: var(--font-weight-regular); | ||
| line-height: var(--line-height-default); | ||
| letter-spacing: var(--letter-spacing-tight); | ||
| color: var(--color-text-secondary); | ||
| } | ||
|
|
||
| .ml-post-auth-modal__list-section { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: var(--space-large); | ||
| padding: 0 var(--space-large) var(--space-large); | ||
| width: 100%; | ||
| box-sizing: border-box; | ||
| } | ||
|
|
||
| /* Override the bottom margin that .mailing-list-modal__list-card applies | ||
| since margin is handled by the parent flex gap here */ | ||
| .ml-post-auth-modal__list-card { | ||
| margin: 0; | ||
| } | ||
|
|
||
| /* Unselect All link + "X of Y selected" counter */ | ||
| .ml-post-auth-modal__footer-row { | ||
| display: flex; | ||
| flex-direction: row; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| width: 100%; | ||
| box-sizing: border-box; | ||
| font-size: var(--font-size-small); | ||
| line-height: var(--line-height-relaxed); | ||
| letter-spacing: var(--letter-spacing-tight); | ||
| white-space: nowrap; | ||
| } | ||
|
|
||
| .ml-post-auth-modal__count { | ||
| font-family: var(--font-sans); | ||
| font-weight: var(--font-weight-regular); | ||
| color: var(--color-text-secondary); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Roll back only rows created by this request.
The rollback path deletes every
UserMailingListSubscriptionmatchinguser/list_id__in=succeeded, butupdate_or_create()does not guarantee those rows were created here. A duplicate submit race can hitcreated=False, and an email failure in this request would then delete the other request’s pending row. Track the created PKs (or thecreatedflag) and only delete those on rollback.Suggested fix
def _subscribe_pending( request, user, email: str, list_ids: list[str] ) -> tuple[list[str], str | None]: @@ - succeeded = [] + succeeded = [] + created_pks = [] for lid in list_ids: try: with transaction.atomic(): - UserMailingListSubscription.objects.update_or_create( + sub, created = UserMailingListSubscription.objects.update_or_create( user=user, list_id=lid, defaults={"email": email, "status": SubscriptionStatus.PENDING}, ) + if created: + created_pks.append(sub.pk) succeeded.append(lid) except IntegrityError: pass @@ try: _send_confirmation_email(request, email, user.pk, succeeded) except Exception as exc: logger.error("Failed to send confirmation email to %s: %s", email, exc) - UserMailingListSubscription.objects.filter( - user=user, list_id__in=succeeded - ).delete() + UserMailingListSubscription.objects.filter(pk__in=created_pks).delete() return [], "Could not send confirmation email. Please try again."📝 Committable suggestion
🧰 Tools
🪛 Ruff (0.15.18)
[warning] 113-113: Do not catch blind exception:
Exception(BLE001)
🤖 Prompt for AI Agents