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:
@@ -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); }
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user