From 77d801e32616c4864e2e6fd7c256bee18ecebc67 Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 19 Feb 2026 23:41:45 +0100 Subject: [PATCH] feat: auto-create Resend audiences per blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes RESEND_AUDIENCE_WAITLIST env var. capture_waitlist_email() now derives audience name from request.blueprints[0] (e.g. waitlist-auth, waitlist-suppliers), lazily creates it via Resend API on first signup, and caches the ID in a new resend_audiences table. Zero config beyond RESEND_API_KEY — adding @waitlist_gate to any new blueprint auto-creates its audience on first use. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 + padelnomics/docs/WAITLIST.md | 76 ++++++++------- padelnomics/src/padelnomics/core.py | 40 +++++--- .../versions/0015_add_resend_audiences.py | 12 +++ padelnomics/tests/test_waitlist.py | 93 ++++++++++++++++--- 5 files changed, 168 insertions(+), 56 deletions(-) create mode 100644 padelnomics/src/padelnomics/migrations/versions/0015_add_resend_audiences.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 45bc689..f29fd33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed +- Auto-create Resend audiences per blueprint: `capture_waitlist_email()` now derives the audience name from `request.blueprints[0]` (e.g., `waitlist-auth`, `waitlist-suppliers`) and lazily creates audiences via the Resend API on first use, caching IDs in a new `resend_audiences` table; removes `RESEND_AUDIENCE_WAITLIST` env var — only `RESEND_API_KEY` needed + ### Added - Simple A/B testing with `@ab_test` decorator and Umami `data-tag` integration - SEO defaults in `base.html`: canonical, og:url, og:type, og:image (logo fallback), og:title, og:description, twitter:card — every page gets these automatically, child templates override as needed diff --git a/padelnomics/docs/WAITLIST.md b/padelnomics/docs/WAITLIST.md index 4b99ee1..067e739 100644 --- a/padelnomics/docs/WAITLIST.md +++ b/padelnomics/docs/WAITLIST.md @@ -1,6 +1,6 @@ # Waitlist Mode -Waitlist mode allows you to validate market demand before building features. Set `WAITLIST_MODE=true` and selected routes will show waitlist signup forms instead of the normal flow. Emails are captured to a database table and a Resend audience for later launch campaigns. +Waitlist mode allows you to validate market demand before building features. Set `WAITLIST_MODE=true` and selected routes will show waitlist signup forms instead of the normal flow. Emails are captured to a database table and automatically segmented into per-blueprint Resend audiences for targeted launch campaigns. ## Use Cases @@ -16,7 +16,7 @@ Waitlist mode allows you to validate market demand before building features. Set ```bash # Add to .env WAITLIST_MODE=true -RESEND_AUDIENCE_WAITLIST=aud_xxx # Optional: Resend audience ID for bulk campaigns +RESEND_API_KEY=re_xyz123 # Optional: audiences created automatically per blueprint ``` ### 2. Run migration (if not already done) @@ -320,49 +320,54 @@ Edit `handle_send_waitlist_confirmation()` in `worker.py` to: ## Resend Integration -If `RESEND_AUDIENCE_WAITLIST` is set, all waitlist signups are automatically added to that Resend audience. This enables bulk launch campaigns when you're ready to go live. +Audiences are created automatically per blueprint — no configuration needed beyond `RESEND_API_KEY`. The first signup from each blueprint creates a named audience in Resend and caches its ID in the `resend_audiences` database table. Subsequent signups from the same blueprint skip the API call and use the cached ID. + +### Audience names + +| Route | Blueprint | Resend audience | +|-------|-----------|-----------------| +| `/auth/signup` | `auth` | `waitlist-auth` | +| `/suppliers/signup/waitlist` | `suppliers` | `waitlist-suppliers` | +| `/planner/export` | `planner` | `waitlist-planner` | + +Adding `@waitlist_gate` to any new blueprint automatically creates its own audience on first signup. ### Setup -1. Create an audience in Resend -2. Copy the audience ID (e.g., `aud_abc123xyz`) -3. Add to `.env`: +Set `RESEND_API_KEY` in `.env` — no audience IDs needed: ```bash -RESEND_AUDIENCE_WAITLIST=aud_abc123xyz +RESEND_API_KEY=re_xyz123 ``` ### How it works -The `capture_waitlist_email()` helper automatically adds emails to the audience: - ```python -if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY: - try: - resend.api_key = config.RESEND_API_KEY - resend.Contacts.create({ - "email": email, - "audience_id": config.RESEND_AUDIENCE_WAITLIST, - }) - except Exception: - pass # Silent fail - not critical +# Derives audience name from current request's blueprint +blueprint = request.blueprints[0] if request.blueprints else "default" +audience_name = f"waitlist-{blueprint}" + +# Lazy-creates audience on first use, caches ID in resend_audiences table +audience_id = await _get_or_create_resend_audience(audience_name) +if audience_id: + resend.Contacts.create({"email": email, "audience_id": audience_id}) ``` **Silent failures:** +- `Audiences.create` error → `audience_id` is None, contact skipped +- `Contacts.create` error → ignored - Duplicate emails (already in audience) → ignored -- Rate limits → ignored -- API errors → ignored +- API rate limits → ignored -This ensures waitlist signups always succeed even if Resend is down. +Waitlist signups always succeed even if Resend is down. ### Launch campaign When ready to launch: -1. Go to Resend dashboard → Audiences → your waitlist audience -2. Export contacts (CSV) -3. Create a broadcast email: "We're live! Here's your early access..." -4. Send to the audience +1. Go to Resend dashboard → Audiences → select the segment (e.g., `waitlist-suppliers`) +2. Create a broadcast email: "We're live! Here's your early access..." +3. Send to the audience --- @@ -513,15 +518,15 @@ SELECT DISTINCT email FROM waitlist; | Variable | Type | Default | Description | |----------|------|---------|-------------| | `WAITLIST_MODE` | bool | `false` | Enable waitlist gates on routes | -| `RESEND_AUDIENCE_WAITLIST` | string | `""` | Resend audience ID for bulk campaigns | +| `RESEND_API_KEY` | string | `""` | Resend API key — audiences created automatically per blueprint | ### Usage ```bash # .env WAITLIST_MODE=true -RESEND_AUDIENCE_WAITLIST=aud_abc123xyz RESEND_API_KEY=re_xyz123 +# No audience IDs needed — created automatically on first signup ``` ### Config Access @@ -675,8 +680,8 @@ uv run python -m padelnomics.worker **Symptoms:** Emails captured, confirmations sent, but not appearing in Resend audience **Causes:** -1. `RESEND_AUDIENCE_WAITLIST` not set -2. Invalid audience ID +1. `RESEND_API_KEY` not set +2. `Audiences.create` API error on first signup (check logs) 3. Resend API rate limit 4. Contacts already in audience (silent duplicate) @@ -685,12 +690,17 @@ uv run python -m padelnomics.worker # Check config uv run python -c " from padelnomics.core import config -print('Audience:', config.RESEND_AUDIENCE_WAITLIST) print('API key:', 'Set' if config.RESEND_API_KEY else 'Not set') " -# Verify audience ID in Resend dashboard -# Format: aud_xxxxxxxxxxxxx +# Check resend_audiences table for cached IDs +uv run python -c " +import sqlite3 +conn = sqlite3.connect('data/app.db') +rows = conn.execute('SELECT * FROM resend_audiences').fetchall() +for row in rows: + print(row) +" ``` ### Decorator not intercepting GET requests @@ -750,7 +760,7 @@ async def signup(): # Enqueue email await enqueue("send_waitlist_confirmation", {"email": email, "intent": "signup"}) - # Add to Resend + # Add to Resend (old: manual audience ID required) if config.RESEND_AUDIENCE_WAITLIST: try: resend.Contacts.create(...) diff --git a/padelnomics/src/padelnomics/core.py b/padelnomics/src/padelnomics/core.py index 0facc4a..2bde6e9 100644 --- a/padelnomics/src/padelnomics/core.py +++ b/padelnomics/src/padelnomics/core.py @@ -60,8 +60,6 @@ class Config: ] RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "") WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true" - # Optional Resend audience ID for bulk launch blast when waitlist is ready - RESEND_AUDIENCE_WAITLIST: str = os.getenv("RESEND_AUDIENCE_WAITLIST", "") RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) @@ -205,6 +203,25 @@ async def send_email( # Waitlist # ============================================================================= +async def _get_or_create_resend_audience(name: str) -> str | None: + """Get cached Resend audience ID, or create one via API. Returns None on failure.""" + row = await fetch_one("SELECT audience_id FROM resend_audiences WHERE name = ?", (name,)) + if row: + return row["audience_id"] + + try: + resend.api_key = config.RESEND_API_KEY + result = resend.Audiences.create({"name": name}) + audience_id = result["id"] + await execute( + "INSERT OR IGNORE INTO resend_audiences (name, audience_id) VALUES (?, ?)", + (name, audience_id), + ) + return audience_id + except Exception: + return None + + async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool: """Insert email into waitlist, enqueue confirmation, add to Resend audience. @@ -235,15 +252,16 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value}) # Add to Resend audience (silent fail - not critical) - if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY: - try: - resend.api_key = config.RESEND_API_KEY - resend.Contacts.create({ - "email": email, - "audience_id": config.RESEND_AUDIENCE_WAITLIST, - }) - except Exception: - pass # Silent fail + if config.RESEND_API_KEY: + blueprint = request.blueprints[0] if request.blueprints else "default" + audience_name = f"waitlist-{blueprint}" + audience_id = await _get_or_create_resend_audience(audience_name) + if audience_id: + try: + resend.api_key = config.RESEND_API_KEY + resend.Contacts.create({"email": email, "audience_id": audience_id}) + except Exception: + pass # Silent fail return is_new diff --git a/padelnomics/src/padelnomics/migrations/versions/0015_add_resend_audiences.py b/padelnomics/src/padelnomics/migrations/versions/0015_add_resend_audiences.py new file mode 100644 index 0000000..fbde714 --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/versions/0015_add_resend_audiences.py @@ -0,0 +1,12 @@ +"""Cache table for lazily-created Resend audience IDs.""" + + +def up(conn): + conn.execute(""" + CREATE TABLE IF NOT EXISTS resend_audiences ( + name TEXT PRIMARY KEY, + audience_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.commit() diff --git a/padelnomics/tests/test_waitlist.py b/padelnomics/tests/test_waitlist.py index daaca72..d786e18 100644 --- a/padelnomics/tests/test_waitlist.py +++ b/padelnomics/tests/test_waitlist.py @@ -79,11 +79,6 @@ class TestConfiguration: assert hasattr(core.config, "WAITLIST_MODE") assert isinstance(core.config.WAITLIST_MODE, bool) - def test_resend_audience_waitlist_exists(self): - """RESEND_AUDIENCE_WAITLIST config should exist.""" - assert hasattr(core.config, "RESEND_AUDIENCE_WAITLIST") - assert isinstance(core.config.RESEND_AUDIENCE_WAITLIST, str) - def test_waitlist_mode_can_be_enabled(self): """WAITLIST_MODE can be set to True via config.""" with patch.object(core.config, "WAITLIST_MODE", True): @@ -385,22 +380,95 @@ class TestAuthRoutes: @pytest.mark.asyncio async def test_adds_to_resend_audience_when_configured(self, client, db): - """POST adds email to Resend audience if RESEND_AUDIENCE_WAITLIST is set.""" + """POST auto-creates Resend audience per blueprint and adds the contact.""" with patch.object(core.config, "WAITLIST_MODE", True), \ - patch.object(core.config, "RESEND_AUDIENCE_WAITLIST", "aud_test123"), \ patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \ patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \ - patch("resend.Contacts.create") as mock_resend: + patch("resend.Audiences.create", return_value={"id": "aud_test"}) as mock_create_aud, \ + patch("resend.Contacts.create") as mock_create_contact: await client.post("/auth/signup", form={ "csrf_token": "test_token", "email": "test@example.com", }) - mock_resend.assert_called_once() - call_args = mock_resend.call_args[0][0] + mock_create_aud.assert_called_once_with({"name": "waitlist-auth"}) + mock_create_contact.assert_called_once() + call_args = mock_create_contact.call_args[0][0] assert call_args["email"] == "test@example.com" - assert call_args["audience_id"] == "aud_test123" + assert call_args["audience_id"] == "aud_test" + + @pytest.mark.asyncio + async def test_resend_audience_cached_on_second_signup(self, client, db): + """Second signup for same blueprint reuses cached audience — no extra API call.""" + with patch.object(core.config, "WAITLIST_MODE", True), \ + patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \ + patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \ + patch("resend.Audiences.create", return_value={"id": "aud_cached"}) as mock_create_aud, \ + patch("resend.Contacts.create"): + + await client.post("/auth/signup", form={ + "csrf_token": "test_token", + "email": "first@example.com", + }) + await client.post("/auth/signup", form={ + "csrf_token": "test_token", + "email": "second@example.com", + }) + + # Audience created only once; second call reads from DB cache + mock_create_aud.assert_called_once() + + @pytest.mark.asyncio + async def test_supplier_and_auth_signups_go_to_different_audiences(self, client, db): + """Auth and supplier signups are added to separate Resend audiences.""" + with patch.object(core.config, "WAITLIST_MODE", True), \ + patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \ + patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \ + patch("resend.Audiences.create", side_effect=[ + {"id": "aud_auth"}, + {"id": "aud_suppliers"}, + ]) as mock_create_aud, \ + patch("resend.Contacts.create") as mock_create_contact: + + await client.post("/auth/signup", form={ + "csrf_token": "test_token", + "email": "entrepreneur@example.com", + }) + await client.post("/suppliers/signup/waitlist", form={ + "csrf_token": "test_token", + "email": "supplier@example.com", + "plan": "supplier_growth", + }) + + audience_names = [ + call[0][0]["name"] for call in mock_create_aud.call_args_list + ] + assert "waitlist-auth" in audience_names + assert "waitlist-suppliers" in audience_names + + contact_audience_ids = [ + call[0][0]["audience_id"] for call in mock_create_contact.call_args_list + ] + assert "aud_auth" in contact_audience_ids + assert "aud_suppliers" in contact_audience_ids + + @pytest.mark.asyncio + async def test_resend_audience_create_failure_still_succeeds(self, client, db): + """Audiences.create failure is silent — signup still returns success page.""" + with patch.object(core.config, "WAITLIST_MODE", True), \ + patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \ + patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \ + patch("resend.Audiences.create", side_effect=Exception("Resend down")): + + response = await client.post("/auth/signup", form={ + "csrf_token": "test_token", + "email": "test@example.com", + }) + + assert response.status_code == 200 + html = await response.get_data(as_text=True) + assert "test@example.com" in html # ── TestSupplierRoutes ──────────────────────────────────────────── @@ -589,8 +657,9 @@ class TestEdgeCases: async def test_resend_api_error_handled_gracefully(self, client, db): """Resend API errors don't break the flow.""" with patch.object(core.config, "WAITLIST_MODE", True), \ - patch.object(core.config, "RESEND_AUDIENCE_WAITLIST", "aud_test"), \ + patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \ patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \ + patch("resend.Audiences.create", return_value={"id": "aud_test"}), \ patch("resend.Contacts.create", side_effect=Exception("API error")): response = await client.post("/auth/signup", form={