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:
@@ -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
|
||||
|
||||
@@ -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(...)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user