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]
### 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

View File

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

View File

@@ -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,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})
# 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:
resend.api_key = config.RESEND_API_KEY
resend.Contacts.create({
"email": email,
"audience_id": config.RESEND_AUDIENCE_WAITLIST,
})
resend.Contacts.create({"email": email, "audience_id": audience_id})
except Exception:
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 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={