Membership: newsletter opt-in at signup and on the profile

An unticked checkbox on registration subscribes the verified address to
the listmonk list (preconfirmed) once the account exists. The profile
page gets a subscribe/unsubscribe toggle, and verified email changes
follow through to listmonk. Newsletter outages never block accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 20:27:40 -05:00
co-authored by Claude Opus 5.5
parent 5954ec1061
commit 98e09627ed
10 changed files with 463 additions and 5 deletions
+57 -4
View File
@@ -24,7 +24,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from . import billing, directory, profiles, shkeeper
from . import billing, directory, newsletter, profiles, shkeeper
from .forms import PasswordForm, RegistrationForm
from .service_groups import configured_groups, service_access_catalog
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
@@ -93,7 +93,7 @@ def register(request):
return HttpResponse("Please try again later.", status=429)
token = secrets.token_urlsafe(32)
digest = hashlib.sha256(token.encode()).hexdigest()
Registration.objects.create(token_hash=digest, uid=form.cleaned_data["uid"], email=email, expires_at=timezone.now() + timedelta(hours=1))
Registration.objects.create(token_hash=digest, uid=form.cleaned_data["uid"], email=email, newsletter=form.cleaned_data.get("newsletter", False), expires_at=timezone.now() + timedelta(hours=1))
url = settings.PUBLIC_URL + reverse("members:verify", args=[token])
try:
send_mail("Verify your eom.dev account", f"Finish creating your account (expires in one hour):\n\n{url}\n\nIf you did not request this, ignore this message. Existing accounts are never changed by this link.", settings.DEFAULT_FROM_EMAIL, [email])
@@ -139,10 +139,16 @@ def finish_registration(request):
remember_groups(account)
Registration.objects.filter(uid=registration.uid).delete()
request.session.pop("registration_hash", None)
subscribed = registration.newsletter and _subscribe_newsletter(account)
# Server-side, short-lived proof permits first binding of ONLY the
# account just provisioned in this browser, never an email match.
request.session["registration_identity"] = {"account_id": str(account.pk), "expires_at": (timezone.now() + timedelta(hours=1)).timestamp()}
return page(request, title="Account created", message="Your free eom.dev account is ready. Sign in to explore the community and its services, or choose an optional paid membership for additional benefits.")
message = "Your free eom.dev account is ready. Sign in to explore the community and its services, or choose an optional paid membership for additional benefits."
if subscribed:
message += " You're subscribed to premiere emails; you can change that from your profile."
elif registration.newsletter:
message += " We couldn't sign you up for premiere emails just now; you can try again from your profile."
return page(request, title="Account created", message=message)
except ValueError:
form.add_error(None, "An account with those details may already exist. Try signing in or contact us for help.")
except Exception as exc:
@@ -151,6 +157,26 @@ def finish_registration(request):
return page(request, title="Choose your password", form=form, submit="Create free account")
def _subscribe_newsletter(account):
# The address was verified by the registration link, so it is preconfirmed.
# Best effort: the account already exists whatever happens here.
try:
return newsletter.subscribe(account.email, uid=account.uid) == newsletter.SUBSCRIBED
except Exception as exc:
logger.error("Newsletter subscribe failed account=%s kind=%s", account.pk, type(exc).__name__)
return False
def _newsletter_status(account):
if not newsletter.enabled():
return None
try:
return newsletter.status(account.email)
except Exception as exc:
logger.error("Newsletter status failed account=%s kind=%s", account.pk, type(exc).__name__)
return "unavailable"
def dex():
oauth = OAuth()
remote = oauth.register("dex", client_id=settings.DEX_CLIENT_ID, client_secret=settings.DEX_CLIENT_SECRET, server_metadata_url=settings.DEX_ISSUER.rstrip("/") + "/.well-known/openid-configuration", client_kwargs={"scope": "openid email profile groups federated:id", "code_challenge_method": "S256", "timeout": 10})
@@ -267,7 +293,7 @@ def profile(request):
logger.error("Profile read failed account=%s kind=%s", member.pk, type(exc).__name__)
return render(request, "profile.html", {"account": member, "profile_loaded": False}, status=503)
context = _profile_context(member, values)
context.update(saved=request.GET.get("saved") == "1", email_sent=request.GET.get("email_sent") == "1")
context.update(saved=request.GET.get("saved") == "1", email_sent=request.GET.get("email_sent") == "1", newsletter_status=_newsletter_status(member))
if request.method == "POST":
if limited("profile-write", str(member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
return HttpResponse("Please try again later.", status=429)
@@ -369,10 +395,16 @@ def confirm_profile_email(request):
if member.ldap_uuid != pending.ldap_uuid:
raise ValueError("Directory identity changed.")
profiles.change_email(member, form.cleaned_data["current_password"], pending.email)
old_email = member.email
member.email = pending.email
member.save(update_fields=["email"])
ProfileEmailChange.objects.filter(account=member).delete()
request.session.pop("profile_email_hash", None)
if newsletter.enabled():
try:
newsletter.change_email(old_email, member.email)
except Exception as exc:
logger.error("Newsletter email change failed account=%s kind=%s", member.pk, type(exc).__name__)
request.session.cycle_key()
return redirect(reverse("members:profile") + "?saved=1")
except Exception as exc:
@@ -381,6 +413,27 @@ def confirm_profile_email(request):
return render(request, "profile.html", {"account": request.member, "confirm_email": True, "confirmation_email": pending.email, "confirmation_form": form})
@require_POST
@account_required
def newsletter_preference(request):
member = request.member
action = request.POST.get("action")
if not newsletter.enabled() or action not in ("subscribe", "unsubscribe"):
return HttpResponse(status=400)
if limited("newsletter", str(member.pk), 20) or limited("newsletter-ip", client_ip(request), 40):
return HttpResponse("Please try again later.", status=429)
try:
if action == "subscribe":
result = newsletter.subscribe(member.email, uid=member.uid)
else:
result = newsletter.unsubscribe(member.email)
logger.info("Newsletter preference changed account=%s action=%s result=%s", member.pk, action, result)
except Exception as exc:
logger.error("Newsletter preference failed account=%s kind=%s", member.pk, type(exc).__name__)
return redirect(reverse("members:profile") + "?newsletter=error#newsletter")
return redirect(reverse("members:profile") + "?newsletter=saved#newsletter")
def stripe_redirect(url, host):
parsed = urlsplit(url or "")
if parsed.scheme != "https" or parsed.hostname != host or parsed.username: