feat: auto-create Resend audiences per blueprint

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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-19 23:41:45 +01:00
parent 05b7397687
commit 77d801e326
5 changed files with 168 additions and 56 deletions

View File

@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [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 ### Added
- Simple A/B testing with `@ab_test` decorator and Umami `data-tag` integration - 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 - 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

View File

@@ -1,6 +1,6 @@
# Waitlist Mode # 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 ## Use Cases
@@ -16,7 +16,7 @@ Waitlist mode allows you to validate market demand before building features. Set
```bash ```bash
# Add to .env # Add to .env
WAITLIST_MODE=true 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) ### 2. Run migration (if not already done)
@@ -320,49 +320,54 @@ Edit `handle_send_waitlist_confirmation()` in `worker.py` to:
## Resend Integration ## 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 ### Setup
1. Create an audience in Resend Set `RESEND_API_KEY` in `.env` — no audience IDs needed:
2. Copy the audience ID (e.g., `aud_abc123xyz`)
3. Add to `.env`:
```bash ```bash
RESEND_AUDIENCE_WAITLIST=aud_abc123xyz RESEND_API_KEY=re_xyz123
``` ```
### How it works ### How it works
The `capture_waitlist_email()` helper automatically adds emails to the audience:
```python ```python
if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY: # Derives audience name from current request's blueprint
try: blueprint = request.blueprints[0] if request.blueprints else "default"
resend.api_key = config.RESEND_API_KEY audience_name = f"waitlist-{blueprint}"
resend.Contacts.create({
"email": email, # Lazy-creates audience on first use, caches ID in resend_audiences table
"audience_id": config.RESEND_AUDIENCE_WAITLIST, audience_id = await _get_or_create_resend_audience(audience_name)
}) if audience_id:
except Exception: resend.Contacts.create({"email": email, "audience_id": audience_id})
pass # Silent fail - not critical
``` ```
**Silent failures:** **Silent failures:**
- `Audiences.create` error → `audience_id` is None, contact skipped
- `Contacts.create` error → ignored
- Duplicate emails (already in audience) → ignored - Duplicate emails (already in audience) → ignored
- Rate limits → ignored - API rate limits → ignored
- API errors → ignored
This ensures waitlist signups always succeed even if Resend is down. Waitlist signups always succeed even if Resend is down.
### Launch campaign ### Launch campaign
When ready to launch: When ready to launch:
1. Go to Resend dashboard → Audiences → your waitlist audience 1. Go to Resend dashboard → Audiences → select the segment (e.g., `waitlist-suppliers`)
2. Export contacts (CSV) 2. Create a broadcast email: "We're live! Here's your early access..."
3. Create a broadcast email: "We're live! Here's your early access..." 3. Send to the audience
4. Send to the audience
--- ---
@@ -513,15 +518,15 @@ SELECT DISTINCT email FROM waitlist;
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
|----------|------|---------|-------------| |----------|------|---------|-------------|
| `WAITLIST_MODE` | bool | `false` | Enable waitlist gates on routes | | `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 ### Usage
```bash ```bash
# .env # .env
WAITLIST_MODE=true WAITLIST_MODE=true
RESEND_AUDIENCE_WAITLIST=aud_abc123xyz
RESEND_API_KEY=re_xyz123 RESEND_API_KEY=re_xyz123
# No audience IDs needed — created automatically on first signup
``` ```
### Config Access ### Config Access
@@ -675,8 +680,8 @@ uv run python -m padelnomics.worker
**Symptoms:** Emails captured, confirmations sent, but not appearing in Resend audience **Symptoms:** Emails captured, confirmations sent, but not appearing in Resend audience
**Causes:** **Causes:**
1. `RESEND_AUDIENCE_WAITLIST` not set 1. `RESEND_API_KEY` not set
2. Invalid audience ID 2. `Audiences.create` API error on first signup (check logs)
3. Resend API rate limit 3. Resend API rate limit
4. Contacts already in audience (silent duplicate) 4. Contacts already in audience (silent duplicate)
@@ -685,12 +690,17 @@ uv run python -m padelnomics.worker
# Check config # Check config
uv run python -c " uv run python -c "
from padelnomics.core import config from padelnomics.core import config
print('Audience:', config.RESEND_AUDIENCE_WAITLIST)
print('API key:', 'Set' if config.RESEND_API_KEY else 'Not set') print('API key:', 'Set' if config.RESEND_API_KEY else 'Not set')
" "
# Verify audience ID in Resend dashboard # Check resend_audiences table for cached IDs
# Format: aud_xxxxxxxxxxxxx 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 ### Decorator not intercepting GET requests
@@ -750,7 +760,7 @@ async def signup():
# Enqueue email # Enqueue email
await enqueue("send_waitlist_confirmation", {"email": email, "intent": "signup"}) 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: if config.RESEND_AUDIENCE_WAITLIST:
try: try:
resend.Contacts.create(...) resend.Contacts.create(...)

View File

@@ -60,8 +60,6 @@ class Config:
] ]
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "") RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true" 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_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
@@ -205,6 +203,25 @@ async def send_email(
# Waitlist # 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: 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. """Insert email into waitlist, enqueue confirmation, add to Resend audience.
@@ -235,13 +252,14 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value}) await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value})
# Add to Resend audience (silent fail - not critical) # Add to Resend audience (silent fail - not critical)
if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY: 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: try:
resend.api_key = config.RESEND_API_KEY resend.api_key = config.RESEND_API_KEY
resend.Contacts.create({ resend.Contacts.create({"email": email, "audience_id": audience_id})
"email": email,
"audience_id": config.RESEND_AUDIENCE_WAITLIST,
})
except Exception: except Exception:
pass # Silent fail pass # Silent fail

View File

@@ -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()

View File

@@ -79,11 +79,6 @@ class TestConfiguration:
assert hasattr(core.config, "WAITLIST_MODE") assert hasattr(core.config, "WAITLIST_MODE")
assert isinstance(core.config.WAITLIST_MODE, bool) 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): def test_waitlist_mode_can_be_enabled(self):
"""WAITLIST_MODE can be set to True via config.""" """WAITLIST_MODE can be set to True via config."""
with patch.object(core.config, "WAITLIST_MODE", True): with patch.object(core.config, "WAITLIST_MODE", True):
@@ -385,22 +380,95 @@ class TestAuthRoutes:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_adds_to_resend_audience_when_configured(self, client, db): 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), \ 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.object(core.config, "RESEND_API_KEY", "re_test_key"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \ 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={ await client.post("/auth/signup", form={
"csrf_token": "test_token", "csrf_token": "test_token",
"email": "test@example.com", "email": "test@example.com",
}) })
mock_resend.assert_called_once() mock_create_aud.assert_called_once_with({"name": "waitlist-auth"})
call_args = mock_resend.call_args[0][0] 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["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 ──────────────────────────────────────────── # ── TestSupplierRoutes ────────────────────────────────────────────
@@ -589,8 +657,9 @@ class TestEdgeCases:
async def test_resend_api_error_handled_gracefully(self, client, db): async def test_resend_api_error_handled_gracefully(self, client, db):
"""Resend API errors don't break the flow.""" """Resend API errors don't break the flow."""
with patch.object(core.config, "WAITLIST_MODE", True), \ 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("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Audiences.create", return_value={"id": "aud_test"}), \
patch("resend.Contacts.create", side_effect=Exception("API error")): patch("resend.Contacts.create", side_effect=Exception("API error")):
response = await client.post("/auth/signup", form={ response = await client.post("/auth/signup", form={