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
+3
View File
@@ -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); }
+9
View File
@@ -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):
+11
View File
@@ -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()
@@ -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)),
]
+1
View File
@@ -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)
+144
View File
@@ -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)
+210
View File
@@ -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")
+1
View File
@@ -15,6 +15,7 @@ urlpatterns = [
path("profile/photo/", views.profile_photo, name="profile_photo"),
path("profile/email/verify/<str:token>/", 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/<slug:tier>/", views.checkout, name="checkout"),
path("billing/", views.portal, name="portal"),
path("stripe/webhook/", views.webhook, name="webhook"),
+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:
+19 -1
View File
@@ -43,7 +43,7 @@
</section>
{% else %}
<nav class="profile-jump-links" aria-label="Profile sections">
<a href="#identity">Identity</a><a href="#contact">Contact</a><a href="#addresses">Addresses</a><a href="#security">SSH keys</a><a href="#email">Email</a><a href="#password">Password</a>
<a href="#identity">Identity</a><a href="#contact">Contact</a><a href="#addresses">Addresses</a><a href="#security">SSH keys</a><a href="#email">Email</a>{% if newsletter_status %}<a href="#newsletter">Newsletter</a>{% endif %}<a href="#password">Password</a>
</nav>
<form action="{% url 'members:profile' %}" method="post" enctype="multipart/form-data" class="profile-edit-form">
{% csrf_token %}
@@ -102,6 +102,24 @@
<button class="button button-primary" type="submit">Send verification email</button>
</form>
</section>
{% if newsletter_status %}
<section id="newsletter" class="member-panel profile-section" aria-labelledby="newsletter-heading">
<h2 id="newsletter-heading">Newsletter</h2>
{% if request.GET.newsletter == "saved" %}<p class="member-notice" role="status">Your newsletter preference has been saved.</p>{% elif request.GET.newsletter == "error" %}<p class="profile-errors" role="alert">Your newsletter preference couldn't be changed just now. Please try again later.</p>{% endif %}
<p>One short email per premiere (about twice a month), plus the monthly essay. Sent to {{ account.email }}.</p>
{% if newsletter_status == "subscribed" %}
<p>You're subscribed.</p>
<form action="{% url 'members:newsletter' %}" method="post">{% csrf_token %}<input type="hidden" name="action" value="unsubscribe"><button class="button button-secondary" type="submit">Unsubscribe</button></form>
{% elif newsletter_status == "unsubscribed" %}
<p>You're not subscribed.</p>
<form action="{% url 'members:newsletter' %}" method="post">{% csrf_token %}<input type="hidden" name="action" value="subscribe"><button class="button button-primary" type="submit">Subscribe</button></form>
{% elif newsletter_status == "blocked" %}
<p>This address has opted out of all eom.dev email. Contact eric@eom.dev to opt back in.</p>
{% else %}
<p>Newsletter settings are temporarily unavailable.</p>
{% endif %}
</section>
{% endif %}
<section id="password" class="member-panel profile-section" aria-labelledby="password-heading">
<h2 id="password-heading">Password</h2>
<p>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.</p>