From 98e09627ed5f5322a2a753bf60603c9b42974cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:27:40 -0500 Subject: [PATCH] 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 --- htdocs/static/membership.css | 3 + membership/config/settings.py | 9 + membership/members/forms.py | 11 + .../0006_registration_newsletter.py | 8 + membership/members/models.py | 1 + membership/members/newsletter.py | 144 ++++++++++++ membership/members/tests/test_newsletter.py | 210 ++++++++++++++++++ membership/members/urls.py | 1 + membership/members/views.py | 61 ++++- membership/templates/profile.html | 20 +- 10 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 membership/members/migrations/0006_registration_newsletter.py create mode 100644 membership/members/newsletter.py create mode 100644 membership/members/tests/test_newsletter.py diff --git a/htdocs/static/membership.css b/htdocs/static/membership.css index 9f50a8d..4414d5a 100755 --- a/htdocs/static/membership.css +++ b/htdocs/static/membership.css @@ -41,6 +41,9 @@ button.button { cursor: pointer; font-size: inherit; line-height: inherit; } .form-panel h1 { font-size: clamp(2rem, 5vw, 3rem); } .form-panel label { display: block; margin-bottom: .3rem; font-weight: 600; } .form-panel input { width: 100%; padding: .8rem 1rem; border-radius: .6rem; border: 1px solid var(--border); background: var(--page-deep); color: var(--text); font: inherit; } +.form-panel p:has(> input[type="checkbox"]) { display: flex; flex-direction: row-reverse; justify-content: flex-end; align-items: flex-start; gap: .6rem; } +.form-panel p:has(> input[type="checkbox"]) label { margin: 0; font-weight: 500; } +.form-panel input[type="checkbox"] { flex: none; width: 1.2rem; height: 1.2rem; margin-top: .2rem; padding: 0; accent-color: var(--accent); } .form-panel input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .form-panel .helptext { display: block; color: var(--muted); font-size: .85rem; margin-top: .4rem; } .errorlist { color: var(--red); } diff --git a/membership/config/settings.py b/membership/config/settings.py index 453c94e..69cddeb 100755 --- a/membership/config/settings.py +++ b/membership/config/settings.py @@ -115,6 +115,15 @@ if SHKEEPER_URL: raise ImproperlyConfigured("SHKEEPER_URL must be a certificate-verified HTTPS origin.") if any(not re.fullmatch(r"[A-Z0-9][A-Z0-9-]{0,31}", value) for value in SHKEEPER_CURRENCIES): raise ImproperlyConfigured("SHKEEPER_CURRENCIES must contain API crypto identifiers such as BTC or ETH-USDT.") +LISTMONK_URL = os.environ.get("LISTMONK_URL", "").rstrip("/") +LISTMONK_API_USER = os.environ.get("LISTMONK_API_USER", "") +LISTMONK_API_TOKEN = os.environ.get("LISTMONK_API_TOKEN", "") +LISTMONK_LIST_NAME = os.environ.get("LISTMONK_LIST_NAME", "eom.dev") +LISTMONK_ENABLED = bool(LISTMONK_URL and LISTMONK_API_USER and LISTMONK_API_TOKEN and LISTMONK_LIST_NAME) +if LISTMONK_URL: + listmonk_origin = urlsplit(LISTMONK_URL) + if listmonk_origin.scheme != "https" or not listmonk_origin.hostname or listmonk_origin.username or listmonk_origin.path or listmonk_origin.query or listmonk_origin.fragment: + raise ImproperlyConfigured("LISTMONK_URL must be a certificate-verified HTTPS origin.") tier_file = os.environ.get("MEMBERSHIP_TIERS_FILE", "") MEMBERSHIP_TIERS = json.loads(os.environ["MEMBERSHIP_TIERS_JSON"]) if "MEMBERSHIP_TIERS_JSON" in os.environ else json.loads(Path(tier_file).read_text(encoding="utf-8")) if tier_file else {} if not isinstance(MEMBERSHIP_TIERS, dict): diff --git a/membership/members/forms.py b/membership/members/forms.py index 0e2f2bf..27ff20f 100755 --- a/membership/members/forms.py +++ b/membership/members/forms.py @@ -1,10 +1,21 @@ from django import forms +from django.conf import settings from django.contrib.auth.password_validation import validate_password +NEWSLETTER_LABEL = "Email me when new works premiere (about twice a month). Unsubscribe any time." + + class RegistrationForm(forms.Form): uid = forms.RegexField(regex=r"^[a-z][a-z0-9._-]{2,47}$", max_length=48, label="Username", help_text="3–48 lowercase letters, numbers, dots, underscores or hyphens. Start with a letter.") email = forms.EmailField(max_length=254) + # Explicit opt-in: unticked by default, and only offered when configured. + newsletter = forms.BooleanField(required=False, label=NEWSLETTER_LABEL) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not settings.LISTMONK_ENABLED: + del self.fields["newsletter"] def clean_email(self): return self.cleaned_data["email"].strip().casefold() diff --git a/membership/members/migrations/0006_registration_newsletter.py b/membership/members/migrations/0006_registration_newsletter.py new file mode 100644 index 0000000..e25801a --- /dev/null +++ b/membership/members/migrations/0006_registration_newsletter.py @@ -0,0 +1,8 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("members", "0005_profile_email_change")] + operations = [ + migrations.AddField(model_name="registration", name="newsletter", field=models.BooleanField(default=False)), + ] diff --git a/membership/members/models.py b/membership/members/models.py index 5922317..10a6649 100755 --- a/membership/members/models.py +++ b/membership/members/models.py @@ -25,6 +25,7 @@ class Registration(models.Model): token_hash = models.CharField(max_length=64, primary_key=True) uid = models.CharField(max_length=48) email = models.EmailField() + newsletter = models.BooleanField(default=False) expires_at = models.DateTimeField(db_index=True) diff --git a/membership/members/newsletter.py b/membership/members/newsletter.py new file mode 100644 index 0000000..a9485b9 --- /dev/null +++ b/membership/members/newsletter.py @@ -0,0 +1,144 @@ +"""listmonk API integration for the eom.dev newsletter. + +Only addresses this app has already verified are sent. The API user can see +and manage subscribers on the newsletter list and nothing else. Every call is +best effort: membership must never fail because the newsletter is unavailable. +""" + +import json +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +SUBSCRIBED = "subscribed" +UNSUBSCRIBED = "unsubscribed" +BLOCKED = "blocked" + +_list_id = None + + +def enabled(): + return settings.LISTMONK_ENABLED + + +def _request(method, path, *, params=None, payload=None): + # Origin and credentials come only from settings; no redirects, so the + # token is never forwarded. TLS verification cannot be disabled. + with requests.request( + method, + settings.LISTMONK_URL + path, + params=params, + json=payload, + headers={"Authorization": f"token {settings.LISTMONK_API_USER}:{settings.LISTMONK_API_TOKEN}"}, + timeout=(5, 10), + allow_redirects=False, + stream=True, + ) as response: + content = bytearray() + for chunk in response.iter_content(16384): + content.extend(chunk) + if len(content) > 1048576: + raise ValueError("listmonk response exceeds the allowed size.") + if response.status_code == 409: + raise Conflict() + if response.status_code != 200: + raise ValueError(f"listmonk returned HTTP {response.status_code}.") + data = json.loads(content) + if not isinstance(data, dict) or "data" not in data: + raise ValueError("listmonk returned an invalid response.") + return data["data"] + + +class Conflict(Exception): + pass + + +def list_id(): + """Resolve the configured list name once; the API user only sees its own lists.""" + global _list_id + if _list_id is None: + lists = _request("GET", "/api/lists", params={"per_page": "all", "minimal": "true"}) + results = lists if isinstance(lists, list) else lists.get("results", []) + matches = [item["id"] for item in results if isinstance(item, dict) and item.get("name") == settings.LISTMONK_LIST_NAME and isinstance(item.get("id"), int)] + if len(matches) != 1: + raise ValueError("Newsletter list is missing or ambiguous.") + _list_id = matches[0] + return _list_id + + +def _find(email): + email = email.strip().casefold() + data = _request("GET", "/api/subscribers", params={"search": email, "per_page": "50"}) + for subscriber in data.get("results", []) if isinstance(data, dict) else []: + if isinstance(subscriber, dict) and str(subscriber.get("email", "")).casefold() == email: + return subscriber + return None + + +def _state(subscriber): + if subscriber is None: + return UNSUBSCRIBED + if subscriber.get("status") == "blocklisted": + return BLOCKED + lid = list_id() + for entry in subscriber.get("lists") or []: + if entry.get("id") == lid and entry.get("subscription_status") in ("confirmed", "unconfirmed"): + return SUBSCRIBED + return UNSUBSCRIBED + + +def status(email): + """SUBSCRIBED, UNSUBSCRIBED or BLOCKED; raises if listmonk is unreachable.""" + list_id() + return _state(_find(email)) + + +def subscribe(email, *, uid, name=""): + """Add a verified address to the list, preconfirmed. Respects the blocklist.""" + lid = list_id() + subscriber = _find(email) + if subscriber is None: + try: + _request("POST", "/api/subscribers", payload={ + "email": email, + "name": name or uid, + "status": "enabled", + "lists": [lid], + "attribs": {"source": "membership", "uid": uid}, + "preconfirm_subscriptions": True, + }) + return SUBSCRIBED + except Conflict: + # Exists, but only on lists this API user cannot see. + logger.warning("Newsletter subscriber exists outside the newsletter list") + raise + if _state(subscriber) == BLOCKED: + return BLOCKED + _request("PUT", "/api/subscribers/lists", payload={"ids": [subscriber["id"]], "action": "add", "target_list_ids": [lid], "status": "confirmed"}) + return SUBSCRIBED + + +def unsubscribe(email): + subscriber = _find(email) + if subscriber is not None: + _request("PUT", "/api/subscribers/lists", payload={"ids": [subscriber["id"]], "action": "unsubscribe", "target_list_ids": [list_id()]}) + return UNSUBSCRIBED + + +def change_email(old, new): + """Follow a verified account e-mail change so mail goes to the new address.""" + subscriber = _find(old) + if subscriber is None or old.strip().casefold() == new.strip().casefold(): + return + existing = _find(new) + if existing is None: + _request("PATCH", f"/api/subscribers/{int(subscriber['id'])}", payload={"email": new}) + return + # The new address is already known (e.g. it used the public form), so an + # in-place rename would collide. Carry the choice over, then retire the old. + if _state(subscriber) == SUBSCRIBED: + subscribe(new, uid=(subscriber.get("attribs") or {}).get("uid", "")) + unsubscribe(old) diff --git a/membership/members/tests/test_newsletter.py b/membership/members/tests/test_newsletter.py new file mode 100644 index 0000000..afad771 --- /dev/null +++ b/membership/members/tests/test_newsletter.py @@ -0,0 +1,210 @@ +import os +import re +import unittest +from unittest.mock import patch + +from django.conf import settings +from django.core import mail +from django.test import SimpleTestCase, TestCase, override_settings +from django.urls import reverse + +from members import newsletter +from members.forms import RegistrationForm +from members.models import Account, Registration + +ENABLED = {"LISTMONK_ENABLED": True, "LISTMONK_URL": "https://listmonk.example.org", "LISTMONK_API_USER": "membership", "LISTMONK_API_TOKEN": "test-token", "LISTMONK_LIST_NAME": "eom.dev"} +DN = "uid=alice,ou=people,dc=example,dc=org" + + +class FakeListmonk: + """Just enough of listmonk's subscriber API, scoped like the real API user.""" + + def __init__(self): + self.lists = [{"id": 3, "name": "eom.dev"}] + self.subscribers = {} + self.calls = [] + + def add(self, email, status="enabled", sub_status="confirmed"): + sid = len(self.subscribers) + 1 + self.subscribers[sid] = {"id": sid, "email": email, "status": status, "lists": [{"id": 3, "subscription_status": sub_status}]} + return sid + + def __call__(self, method, path, *, params=None, payload=None): + self.calls.append((method, path, payload)) + if (method, path) == ("GET", "/api/lists"): + return self.lists + if (method, path) == ("GET", "/api/subscribers"): + return {"results": [s for s in self.subscribers.values() if params["search"] in s["email"]]} + if (method, path) == ("POST", "/api/subscribers"): + if any(s["email"] == payload["email"] for s in self.subscribers.values()): + raise newsletter.Conflict() + self.add(payload["email"]) + return {} + if (method, path) == ("PUT", "/api/subscribers/lists"): + for sid in payload["ids"]: + status = {"add": payload.get("status", "unconfirmed"), "unsubscribe": "unsubscribed"}[payload["action"]] + self.subscribers[sid]["lists"] = [{"id": 3, "subscription_status": status}] + return True + if method == "PATCH": + self.subscribers[int(path.rsplit("/", 1)[1])].update(payload) + return {} + raise AssertionError(f"unexpected call {method} {path}") + + +class NewsletterClientTests(SimpleTestCase): + def setUp(self): + newsletter._list_id = None + self.fake = FakeListmonk() + patcher = patch("members.newsletter._request", side_effect=self.fake) + patcher.start() + self.addCleanup(patcher.stop) + + def writes(self): + return [c for c in self.fake.calls if c[0] != "GET"] + + def test_new_address_is_created_preconfirmed_on_the_list(self): + self.assertEqual(newsletter.subscribe("alice@example.org", uid="alice"), newsletter.SUBSCRIBED) + (method, path, payload), = self.writes() + self.assertEqual((method, path), ("POST", "/api/subscribers")) + self.assertEqual(payload["lists"], [3]) + self.assertTrue(payload["preconfirm_subscriptions"]) + self.assertEqual(payload["attribs"], {"source": "membership", "uid": "alice"}) + self.assertEqual(newsletter.status("alice@example.org"), newsletter.SUBSCRIBED) + + def test_unsubscribed_address_is_resubscribed_and_matches_exactly(self): + self.fake.add("xalice@example.org") + sid = self.fake.add("alice@example.org", sub_status="unsubscribed") + self.assertEqual(newsletter.status("ALICE@example.org"), newsletter.UNSUBSCRIBED) + newsletter.subscribe("alice@example.org", uid="alice") + self.assertEqual(self.writes(), [("PUT", "/api/subscribers/lists", {"ids": [sid], "action": "add", "target_list_ids": [3], "status": "confirmed"})]) + + def test_blocklisted_address_is_never_resubscribed(self): + self.fake.add("alice@example.org", status="blocklisted", sub_status="unsubscribed") + self.assertEqual(newsletter.status("alice@example.org"), newsletter.BLOCKED) + self.assertEqual(newsletter.subscribe("alice@example.org", uid="alice"), newsletter.BLOCKED) + self.assertEqual(self.writes(), []) + + def test_unsubscribe_and_change_email(self): + sid = self.fake.add("alice@example.org") + newsletter.unsubscribe("alice@example.org") + self.assertEqual(newsletter.status("alice@example.org"), newsletter.UNSUBSCRIBED) + newsletter.change_email("alice@example.org", "alice@new.example.org") + self.assertEqual(self.fake.subscribers[sid]["email"], "alice@new.example.org") + newsletter.unsubscribe("nobody@example.org") + newsletter.change_email("nobody@example.org", "x@example.org") + self.assertEqual(len(self.writes()), 2) + + def test_change_email_onto_an_existing_subscriber_merges(self): + old = self.fake.add("alice@example.org") + new = self.fake.add("alice@new.example.org", sub_status="unsubscribed") + newsletter.change_email("alice@example.org", "alice@new.example.org") + self.assertFalse(any(c[0] == "PATCH" for c in self.fake.calls)) + self.assertEqual(newsletter.status("alice@new.example.org"), newsletter.SUBSCRIBED) + self.assertEqual(newsletter.status("alice@example.org"), newsletter.UNSUBSCRIBED) + self.assertNotEqual(old, new) + + def test_missing_list_is_an_error(self): + self.fake.lists = [] + with self.assertRaises(ValueError): + newsletter.status("alice@example.org") + + +class RegistrationFormTests(SimpleTestCase): + def test_checkbox_only_when_configured_and_unticked(self): + with override_settings(LISTMONK_ENABLED=False): + self.assertNotIn("newsletter", RegistrationForm().fields) + with override_settings(**ENABLED): + form = RegistrationForm() + self.assertIn("newsletter", form.fields) + self.assertNotIn("checked", str(form["newsletter"])) + form = RegistrationForm({"uid": "alice", "email": "alice@example.org"}) + self.assertTrue(form.is_valid()) + self.assertFalse(form.cleaned_data["newsletter"]) + + +@override_settings(**ENABLED) +@patch("members.views.directory.create_account", return_value={"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"}) +class SignupTests(TestCase): + password = "correct-horse-purple-planet-943!" + + def sign_up(self, **extra): + self.client.post("/members/register/", {"uid": "alice", "email": "alice@example.org", **extra}) + token = re.search(r"/verify/([^/]+)/", mail.outbox[-1].body).group(1) + self.client.get(f"/members/verify/{token}/") + return self.client.post("/members/register/finish/", {"password": self.password, "confirm_password": self.password}) + + @patch("members.views.newsletter.subscribe", return_value=newsletter.SUBSCRIBED) + def test_opt_in_subscribes_after_the_account_exists(self, subscribe, provision): + subscribe.side_effect = lambda *a, **k: self.assertTrue(Account.objects.exists()) or newsletter.SUBSCRIBED + response = self.sign_up(newsletter="on") + self.assertContains(response, "subscribed to premiere emails") + subscribe.assert_called_once_with("alice@example.org", uid="alice") + + @patch("members.views.newsletter.subscribe") + def test_no_opt_in_no_subscription(self, subscribe, provision): + self.assertContains(self.sign_up(), "Account created") + subscribe.assert_not_called() + + @patch("members.views.newsletter.subscribe", side_effect=ConnectionError("down")) + def test_newsletter_outage_never_blocks_the_account(self, subscribe, provision): + response = self.sign_up(newsletter="on") + self.assertContains(response, "Account created") + self.assertContains(response, "try again from your profile") + self.assertEqual(Account.objects.count(), 1) + self.assertFalse(Registration.objects.exists()) + + +@override_settings(**ENABLED) +class PreferenceTests(TestCase): + def setUp(self): + self.account = Account.objects.create(uid="alice", email="alice@example.org", ldap_dn=DN, ldap_uuid="uuid-alice") + session = self.client.session + session["account_id"] = str(self.account.pk) + session.save() + self.url = reverse("members:newsletter") + + @patch("members.views.newsletter.subscribe", return_value=newsletter.SUBSCRIBED) + @patch("members.views.newsletter.unsubscribe", return_value=newsletter.UNSUBSCRIBED) + def test_toggle_uses_the_account_address(self, unsubscribe, subscribe): + self.assertRedirects(self.client.post(self.url, {"action": "subscribe", "email": "evil@example.org"}), reverse("members:profile") + "?newsletter=saved#newsletter", fetch_redirect_response=False) + subscribe.assert_called_once_with("alice@example.org", uid="alice") + self.client.post(self.url, {"action": "unsubscribe"}) + unsubscribe.assert_called_once_with("alice@example.org") + self.assertEqual(self.client.post(self.url, {"action": "delete"}).status_code, 400) + self.assertEqual(self.client.get(self.url).status_code, 405) + + @patch("members.views.newsletter.subscribe", side_effect=ConnectionError("down")) + def test_outage_reports_error(self, subscribe): + self.assertRedirects(self.client.post(self.url, {"action": "subscribe"}), reverse("members:profile") + "?newsletter=error#newsletter", fetch_redirect_response=False) + + @patch("members.views.newsletter.subscribe") + def test_requires_sign_in_and_configuration(self, subscribe): + self.client.session.flush() + self.client.cookies.clear() + self.assertRedirects(self.client.post(self.url, {"action": "subscribe"}), reverse("members:home"), fetch_redirect_response=False) + with override_settings(LISTMONK_ENABLED=False): + session = self.client.session + session["account_id"] = str(self.account.pk) + session.save() + self.assertEqual(self.client.post(self.url, {"action": "subscribe"}).status_code, 400) + subscribe.assert_not_called() + + +@unittest.skipUnless(os.environ.get("LISTMONK_TEST_URL"), "Set LISTMONK_TEST_URL/USER/TOKEN to test against a disposable listmonk.") +class LiveListmonkTests(SimpleTestCase): + """Against a throwaway listmonk configured by ansible-role-listmonk.""" + + def test_round_trip(self): + with override_settings(LISTMONK_URL=os.environ["LISTMONK_TEST_URL"], LISTMONK_API_USER=os.environ["LISTMONK_TEST_USER"], LISTMONK_API_TOKEN=os.environ["LISTMONK_TEST_TOKEN"], LISTMONK_LIST_NAME="eom.dev"): + newsletter._list_id = None + email = "live-test@example.org" + self.assertEqual(newsletter.subscribe(email, uid="livetest"), newsletter.SUBSCRIBED) + self.assertEqual(newsletter.status(email), newsletter.SUBSCRIBED) + self.assertEqual(newsletter.unsubscribe(email), newsletter.UNSUBSCRIBED) + self.assertEqual(newsletter.status(email), newsletter.UNSUBSCRIBED) + self.assertEqual(newsletter.subscribe(email, uid="livetest"), newsletter.SUBSCRIBED) + self.assertEqual(newsletter.status(email), newsletter.SUBSCRIBED) + newsletter.change_email(email, "live-test-2@example.org") + self.assertEqual(newsletter.status("live-test-2@example.org"), newsletter.SUBSCRIBED) + self.assertEqual(newsletter.status(email), newsletter.UNSUBSCRIBED) + newsletter.unsubscribe("live-test-2@example.org") diff --git a/membership/members/urls.py b/membership/members/urls.py index b2cba8b..6a394e1 100755 --- a/membership/members/urls.py +++ b/membership/members/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path("profile/photo/", views.profile_photo, name="profile_photo"), path("profile/email/verify//", views.verify_profile_email, name="verify_profile_email"), path("profile/email/confirm/", views.confirm_profile_email, name="confirm_profile_email"), + path("profile/newsletter/", views.newsletter_preference, name="newsletter"), path("checkout//", views.checkout, name="checkout"), path("billing/", views.portal, name="portal"), path("stripe/webhook/", views.webhook, name="webhook"), diff --git a/membership/members/views.py b/membership/members/views.py index af54992..a855b93 100755 --- a/membership/members/views.py +++ b/membership/members/views.py @@ -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: diff --git a/membership/templates/profile.html b/membership/templates/profile.html index 291cb66..acf9491 100755 --- a/membership/templates/profile.html +++ b/membership/templates/profile.html @@ -43,7 +43,7 @@ {% else %}
{% csrf_token %} @@ -102,6 +102,24 @@
+ {% if newsletter_status %} +
+

Newsletter

+ {% if request.GET.newsletter == "saved" %}

Your newsletter preference has been saved.

{% elif request.GET.newsletter == "error" %}{% endif %} +

One short email per premiere (about twice a month), plus the monthly essay. Sent to {{ account.email }}.

+ {% if newsletter_status == "subscribed" %} +

You're subscribed.

+
{% csrf_token %}
+ {% elif newsletter_status == "unsubscribed" %} +

You're not subscribed.

+
{% csrf_token %}
+ {% elif newsletter_status == "blocked" %} +

This address has opted out of all eom.dev email. Contact eric@eom.dev to opt back in.

+ {% else %} +

Newsletter settings are temporarily unavailable.

+ {% endif %} +
+ {% endif %}

Password

Choose a strong, unique password for your shared eom.dev account. Your current password is needed to authorize the change. Spaces in passwords are preserved.