diff --git a/docs/superpowers/plans/2026-05-25-beta-mode-and-paid-gap.md b/docs/superpowers/plans/2026-05-25-beta-mode-and-paid-gap.md
new file mode 100644
index 0000000..328fe47
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-25-beta-mode-and-paid-gap.md
@@ -0,0 +1,2096 @@
+# Beta Mode + Paid/Free Gap Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship the closed-beta launch package: a visible BETA chip in the app chrome, a 6-hour free-tier news cap, and email digests (daily for paid Mon–Sat, Sunday weekly for everyone), all gated by an opt-in flag.
+
+**Architecture:** Three coordinated changes layered onto existing FastAPI + APScheduler + MariaDB + OpenRouter + SMTP infrastructure. No new external services. New persisted state: two columns on `users` (`email_digest_opt_in`, `digest_tone`) and one new table (`email_sends`). One new job (`email_digest_job`), one new router (`email`), two new prompt builders.
+
+**Tech Stack:** FastAPI · SQLAlchemy 2.0 (async) · Alembic · APScheduler · aiosmtplib · itsdangerous · pytest + aiosqlite (tests) · Jinja2 templates · vanilla JS + HTMX.
+
+**Spec:** `docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md`
+
+---
+
+## File Map
+
+**New files**
+- `app/jobs/email_digest_job.py` — orchestrator (daily Mon–Sat for paid, weekly Sunday for all opt-in)
+- `app/routers/email.py` — `GET /email/unsubscribe?token=…`
+- `alembic/versions/0017_email_digest.py` — migration
+- `tests/test_news_window.py` — news-cap regression
+- `tests/test_email_digest_job.py` — job recipient selection, idempotency
+- `tests/test_email_unsubscribe.py` — token roundtrip + endpoint
+- `tests/test_email_render.py` — digest email renderer
+- `tests/test_digest_prompts.py` — prompt builder unit tests
+- `tests/test_settings_digest_api.py` — PATCH endpoint
+- `tests/test_verify_subscribe.py` — sign-up checkbox
+
+**Modified files**
+- `app/config.py` — add `BETA_MODE` flag
+- `app/templates_env.py` — expose `BETA_MODE` to templates
+- `app/templates/base.html` — render the chip
+- `app/static/css/cassandra.css` — `.beta-chip` styling
+- `app/services/access.py` — `FREE_NEWS_WINDOW_HOURS` constant
+- `app/routers/api.py` — `news_list` clamps `since_hours` for non-paid; PATCH endpoint for digest prefs
+- `app/templates/partials/news.html` — capped-footer marker
+- `app/models.py` — `User.email_digest_opt_in`, `User.digest_tone`, `EmailSend` model
+- `app/services/openrouter.py` — `build_daily_digest_prompt`, `build_weekly_digest_prompt`, bump `PROMPT_VERSION`
+- `app/services/email_service.py` — `render_digest_email`, `send_digest`
+- `app/scheduler_main.py` — register the digest job at 06:30 UTC
+- `app/templates/settings.html` — Email digests section
+- `app/templates/verify.html` — subscribe checkbox
+- `app/routers/auth.py` — read `subscribe_to_digests` on verify POST
+- `app/templates/pricing.html` — updated tier copy
+- `app/cli.py` — `send-test-digest` command
+- `app/main.py` — include new email router
+
+---
+
+## Task 1: BETA chip
+
+**Files:**
+- Modify: `app/config.py`
+- Modify: `app/templates_env.py`
+- Modify: `app/templates/base.html` (line ~140)
+- Modify: `app/static/css/cassandra.css`
+
+- [ ] **Step 1: Add `BETA_MODE` to config**
+
+Open `app/config.py`. Find the `Settings` class. Add:
+
+```python
+ BETA_MODE: bool = True # Shows a "BETA" pill in the app header. Flip to False at GA.
+```
+
+Place it adjacent to other display/flag-style settings (alongside `CASSANDRA_TONE` or the LLM caps — whichever cluster fits).
+
+- [ ] **Step 2: Expose the flag to templates**
+
+Open `app/templates_env.py`. After the existing `templates.env.globals[...]` block (around line 75), add:
+
+```python
+from app.config import get_settings as _get_settings # if not already imported
+templates.env.globals["BETA_MODE"] = _get_settings().BETA_MODE
+```
+
+If `get_settings` is already imported in the file under another alias, reuse it.
+
+- [ ] **Step 3: Render the chip in the app header**
+
+Open `app/templates/base.html`. Find the brand link (line ~140):
+
+```html
+ {{ BRAND_NAME }}
+```
+
+Insert immediately after it:
+
+```html
+ {% if BETA_MODE %}BETA{% endif %}
+```
+
+- [ ] **Step 4: Style the chip**
+
+Open `app/static/css/cassandra.css`. Append at the bottom of the file:
+
+```css
+/* BETA indicator pill in the app header — see app/templates/base.html. */
+.beta-chip {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 2px 7px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ font-family: var(--font-mono);
+ color: var(--bg);
+ background: var(--accent);
+ border-radius: 2px;
+ vertical-align: middle;
+ user-select: none;
+}
+```
+
+- [ ] **Step 5: Manual visual check**
+
+Run the app locally (`docker compose restart app` — the new volume mount picks up changes). Load any logged-in page and confirm the `BETA` pill renders next to the brand. Confirm the chip does NOT appear on `/pricing` (which uses `public_base.html`, not `base.html`).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/config.py app/templates_env.py app/templates/base.html app/static/css/cassandra.css
+git commit -m "beta: header chip flagged by BETA_MODE config (default on)"
+```
+
+---
+
+## Task 2: Free-tier news window cap
+
+**Files:**
+- Modify: `app/services/access.py`
+- Modify: `app/routers/api.py` (lines 222-280, `news_list`)
+- Modify: `app/templates/partials/news.html`
+- Create: `tests/test_news_window.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/test_news_window.py`:
+
+```python
+"""Free vs paid window clamp on /api/news."""
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+
+def _build_app(tmp_path):
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+ from app import db as db_mod
+ from app.auth import sign_session
+ from app.db import Base
+ from app.models import Headline, User
+ from app.routers import api as api_router
+
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/news.db")
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ db_mod._engine = engine
+ db_mod._session_factory = factory
+
+ now = datetime.now(timezone.utc)
+
+ async def _seed():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ async with factory() as s:
+ s.add(User(id=1, email="free@x", tier="free"))
+ s.add(User(id=2, email="paid@x", tier="paid"))
+ # Headlines: one 1h old, one 12h old, one 20h old.
+ for hours_old, title in ((1, "fresh"), (12, "mid"), (20, "old")):
+ s.add(Headline(
+ source="test", title=title, url=f"https://e/{title}",
+ category="general",
+ published_at=now - timedelta(hours=hours_old),
+ fetched_at=now,
+ tags=[],
+ ))
+ await s.commit()
+
+ asyncio.run(_seed())
+
+ app = FastAPI()
+ app.include_router(api_router.router, prefix="/api")
+ client = TestClient(app)
+ return client, sign_session(1), sign_session(2)
+
+
+@pytest.mark.skipif(False, reason="requires aiosqlite + httpx")
+def test_free_user_clamped_to_6h(tmp_path):
+ client, free_sess, _ = _build_app(tmp_path)
+ r = client.get("/api/news?since_hours=24",
+ cookies={"cassandra_session": free_sess})
+ assert r.status_code == 200
+ titles = [h["title"] for h in r.json()]
+ assert "fresh" in titles
+ assert "mid" not in titles # 12h ago, beyond 6h
+ assert "old" not in titles
+
+
+def test_paid_user_full_24h(tmp_path):
+ client, _, paid_sess = _build_app(tmp_path)
+ r = client.get("/api/news?since_hours=24",
+ cookies={"cassandra_session": paid_sess})
+ assert r.status_code == 200
+ titles = [h["title"] for h in r.json()]
+ assert {"fresh", "mid", "old"} <= set(titles)
+
+
+def test_anonymous_clamped_to_6h(tmp_path):
+ client, _, _ = _build_app(tmp_path)
+ r = client.get("/api/news?since_hours=24")
+ assert r.status_code == 200
+ titles = [h["title"] for h in r.json()]
+ assert "fresh" in titles
+ assert "mid" not in titles
+```
+
+- [ ] **Step 2: Run the failing test**
+
+```bash
+pytest tests/test_news_window.py -v
+```
+
+Expected: FAIL — the clamp doesn't exist yet, so the free/anonymous calls return 12h and 20h headlines too.
+
+- [ ] **Step 3: Add the constant**
+
+Open `app/services/access.py`. Below the imports / above `_utcnow`, add:
+
+```python
+# How many hours of news the free tier sees. Paid sees whatever the
+# endpoint's `since_hours` param requests (up to its own max).
+FREE_NEWS_WINDOW_HOURS = 6.0
+```
+
+- [ ] **Step 4: Clamp the endpoint**
+
+Open `app/routers/api.py`. Find `news_list` (line 222). Update:
+
+```python
+@router.get("/news")
+async def news_list(
+ request: Request,
+ session: AsyncSession = Depends(get_session),
+ principal: CurrentUser | None = Depends(maybe_current_user),
+ category: str | None = Query(None),
+ since_hours: float = Query(24.0, ge=0.1, le=720.0),
+ limit: int = Query(50, ge=1, le=500),
+ tags: str | None = Query(None, description="comma-separated include list"),
+ exclude_tags: str | None = Query(None, description="comma-separated exclude list"),
+ as_: str | None = Query(default=None, alias="as"),
+):
+ from app.services.news_tagging import TAG_LABELS, TAG_VOCABULARY
+ from app.services.access import FREE_NEWS_WINDOW_HOURS, is_paid_active
+
+ effective_hours = since_hours
+ capped = not is_paid_active(principal)
+ if capped:
+ effective_hours = min(since_hours, FREE_NEWS_WINDOW_HOURS)
+
+ cutoff = utcnow() - timedelta(hours=effective_hours)
+ # ... rest of the function unchanged through `filtered = ...` ...
+```
+
+Also add `maybe_current_user` to the imports at the top of `app/routers/api.py`:
+
+```python
+from app.auth import maybe_current_user
+```
+
+(The existing `from app.auth import …` line may already import other names — extend it.)
+
+In the `as_ == "html"` branch, extend the template context with `capped` and `window_hours`:
+
+```python
+ return templates.TemplateResponse(
+ request, "partials/news.html",
+ {"headlines": items,
+ "tag_vocabulary": TAG_VOCABULARY,
+ "tag_labels": TAG_LABELS,
+ "active_include": sorted(include),
+ "active_exclude": sorted(exclude),
+ "capped": capped,
+ "window_hours": effective_hours},
+ )
+```
+
+The JSON branch is unchanged — the test asserts via the JSON shape.
+
+- [ ] **Step 5: Run the test, expect PASS**
+
+```bash
+pytest tests/test_news_window.py -v
+```
+
+Expected: all three tests pass.
+
+- [ ] **Step 6: Add the partial-template footer**
+
+Open `app/templates/partials/news.html`. At the bottom of the file (after the last existing item rendering and any close tags), add:
+
+```html
+{% if capped %}
+
+ Free tier — showing the last {{ window_hours|int }} hours of news.
+ Upgrade
+ for the full 24-hour feed plus daily and weekly email digests.
+
+{% endif %}
+```
+
+- [ ] **Step 7: Run the full suite to catch regressions**
+
+```bash
+pytest tests/ -x -q
+```
+
+Expected: no regressions (the existing `test_news_*` tests don't depend on the clamp).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add app/services/access.py app/routers/api.py app/templates/partials/news.html tests/test_news_window.py
+git commit -m "news: clamp free + anonymous to last 6h; paid keeps 24h"
+```
+
+---
+
+## Task 3: DB model + Alembic migration
+
+**Files:**
+- Modify: `app/models.py`
+- Create: `alembic/versions/0017_email_digest.py`
+
+- [ ] **Step 1: Add columns to `User`**
+
+Open `app/models.py`. Find the `User` class (search for `class User(Base)`). Add two columns alongside the existing tier/credit fields:
+
+```python
+ email_digest_opt_in: Mapped[bool] = mapped_column(
+ Boolean, nullable=False, default=True, server_default=text("1"),
+ )
+ # NULL = use INTERMEDIATE at render time. Server-side mirror of the
+ # dashboard tone, decoupled because the dashboard pref is localStorage.
+ digest_tone: Mapped[str | None] = mapped_column(String(16))
+```
+
+If `Boolean` or `text` aren't already imported, extend the SQLAlchemy import line at the top of the file:
+
+```python
+from sqlalchemy import (..., Boolean, text, ...)
+```
+
+- [ ] **Step 2: Add the `EmailSend` model**
+
+In the same file, after the existing model definitions, add:
+
+```python
+class EmailSend(Base):
+ """Audit row per digest email send. Used for idempotency (don't send
+ twice on the same UTC day) and for surfacing 'last delivery' on the
+ Settings page."""
+ __tablename__ = "email_sends"
+
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+ user_id: Mapped[int] = mapped_column(
+ ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
+ )
+ kind: Mapped[str] = mapped_column(String(16), nullable=False) # "daily" | "weekly"
+ sent_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=utcnow, nullable=False,
+ )
+ status: Mapped[str] = mapped_column(String(16), nullable=False) # "sent" | "skipped" | "error"
+ error: Mapped[str | None] = mapped_column(String(255))
+
+ __table_args__ = (
+ Index("ix_email_sends_user_kind_sent", "user_id", "kind", "sent_at"),
+ )
+```
+
+Add any missing imports (`BigInteger`, `ForeignKey`, `Index`) at the top.
+
+- [ ] **Step 3: Create the Alembic migration**
+
+Create `alembic/versions/0017_email_digest.py`:
+
+```python
+"""email digests: User.email_digest_opt_in, User.digest_tone, email_sends table.
+
+Revision ID: 0017
+Revises: 0016
+Create Date: 2026-05-25
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision: str = "0017"
+down_revision: Union[str, None] = "0016"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "users",
+ sa.Column(
+ "email_digest_opt_in", sa.Boolean(), nullable=False,
+ server_default=sa.text("1"),
+ ),
+ )
+ op.add_column(
+ "users",
+ sa.Column("digest_tone", sa.String(length=16), nullable=True),
+ )
+
+ op.create_table(
+ "email_sends",
+ sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True),
+ sa.Column(
+ "user_id", sa.Integer(), nullable=False,
+ ),
+ sa.Column("kind", sa.String(length=16), nullable=False),
+ sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("status", sa.String(length=16), nullable=False),
+ sa.Column("error", sa.String(length=255), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["user_id"], ["users.id"], ondelete="CASCADE",
+ ),
+ )
+ op.create_index(
+ "ix_email_sends_user_kind_sent",
+ "email_sends",
+ ["user_id", "kind", "sent_at"],
+ )
+ op.create_index(
+ op.f("ix_email_sends_user_id"), "email_sends", ["user_id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_email_sends_user_id"), table_name="email_sends")
+ op.drop_index("ix_email_sends_user_kind_sent", table_name="email_sends")
+ op.drop_table("email_sends")
+ op.drop_column("users", "digest_tone")
+ op.drop_column("users", "email_digest_opt_in")
+```
+
+- [ ] **Step 4: Apply the migration to the dev DB**
+
+```bash
+docker compose exec app alembic upgrade head
+```
+
+Expected output ends with: `Running upgrade 0016 -> 0017, email digests...`
+
+- [ ] **Step 5: Verify the columns exist**
+
+```bash
+docker compose exec db mysql -ucassandra -p${MARIADB_PASSWORD} -e "DESCRIBE cassandra.users;" | grep -E "email_digest_opt_in|digest_tone"
+docker compose exec db mysql -ucassandra -p${MARIADB_PASSWORD} -e "DESCRIBE cassandra.email_sends;"
+```
+
+Expected: both new columns on `users` and the full `email_sends` table appear.
+
+- [ ] **Step 6: Run the test suite — confirm no regressions from the model change**
+
+```bash
+pytest tests/ -x -q
+```
+
+Expected: all existing tests still pass (no test depends on the old User shape).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/models.py alembic/versions/0017_email_digest.py
+git commit -m "db: add digest opt-in/tone on users, email_sends audit table"
+```
+
+---
+
+## Task 4: Daily / Weekly digest prompts
+
+**Files:**
+- Modify: `app/services/openrouter.py`
+- Create: `tests/test_digest_prompts.py`
+
+- [ ] **Step 1: Inspect the existing prompt scaffolding**
+
+Read `app/services/openrouter.py` to confirm the shape of `build_system_prompt(tone, analysis)` and `build_user_prompt(...)`. The new functions will mirror that contract: return `(system_prompt, user_prompt)` strings ready for `call_llm`.
+
+- [ ] **Step 2: Write the failing test**
+
+Create `tests/test_digest_prompts.py`:
+
+```python
+"""Unit tests for the daily / weekly digest prompt builders."""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from app.services.openrouter import (
+ build_daily_digest_prompt,
+ build_weekly_digest_prompt,
+)
+
+
+def _ctx():
+ return dict(
+ today=datetime(2026, 5, 25, 6, 30, tzinfo=timezone.utc),
+ quotes_by_group={"equities": [{"symbol": "SPX", "price": 7500.0,
+ "label": "S&P 500", "currency": "USD",
+ "source": "test", "note": "",
+ "as_of": None, "changes": {}}]},
+ headlines_by_bucket={"general": [{"when": "2026-05-25T05:00:00+00:00",
+ "source": "FT", "title": "Brent slides"}]},
+ reference_line="S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45%",
+ )
+
+
+def test_daily_prompt_tone_intermediate():
+ sys_, usr = build_daily_digest_prompt(tone="INTERMEDIATE", **_ctx())
+ assert "INTERMEDIATE" in sys_.upper() or "intermediate" in sys_.lower()
+ assert "Brent slides" in usr
+ assert "daily" in sys_.lower()
+
+
+def test_daily_prompt_tone_novice_differs():
+ sys_int, _ = build_daily_digest_prompt(tone="INTERMEDIATE", **_ctx())
+ sys_nov, _ = build_daily_digest_prompt(tone="NOVICE", **_ctx())
+ assert sys_int != sys_nov
+
+
+def test_weekly_prompt_mentions_week():
+ sys_, usr = build_weekly_digest_prompt(tone="INTERMEDIATE", **_ctx())
+ assert "week" in sys_.lower() or "weekly" in sys_.lower()
+ assert "Brent slides" in usr
+
+
+def test_prompts_return_strings():
+ for fn in (build_daily_digest_prompt, build_weekly_digest_prompt):
+ sys_, usr = fn(tone="INTERMEDIATE", **_ctx())
+ assert isinstance(sys_, str) and isinstance(usr, str)
+ assert len(sys_) > 50 and len(usr) > 50
+```
+
+- [ ] **Step 3: Run the failing test**
+
+```bash
+pytest tests/test_digest_prompts.py -v
+```
+
+Expected: FAIL — `ImportError: cannot import name 'build_daily_digest_prompt'`.
+
+- [ ] **Step 4: Implement the prompt builders**
+
+Open `app/services/openrouter.py`. After `build_user_prompt`, add:
+
+```python
+def build_daily_digest_prompt(
+ *,
+ tone: str,
+ today,
+ quotes_by_group: dict,
+ headlines_by_bucket: dict,
+ reference_line: str,
+) -> tuple[str, str]:
+ """System + user prompt for the once-a-day editorial digest.
+
+ Different from the hourly log: the daily digest reflects on the past
+ 24h and looks forward to the upcoming session. Longer, less
+ 'live-blogging,' more contextual. Target ~600 words."""
+ tone_clause = (
+ "Use plain English. Define any jargon on first use."
+ if tone.upper() == "NOVICE"
+ else "Write for a reader who already speaks markets fluently."
+ )
+ system = (
+ "You write the daily editorial digest for Read the Markets. "
+ f"Audience tone: {tone.upper()}. {tone_clause} "
+ "Cover: (1) what mattered yesterday, (2) what to watch in today's "
+ "EU and US sessions, (3) one cross-asset thread connecting them. "
+ "No predictions of price level, no buy/sell language. Target ~600 "
+ "words. Output HTML using only
,
,
,
, , "
+ " — no , , or wrapper, no inline styles."
+ )
+ user = _digest_user_prompt(today, quotes_by_group, headlines_by_bucket, reference_line)
+ return system, user
+
+
+def build_weekly_digest_prompt(
+ *,
+ tone: str,
+ today,
+ quotes_by_group: dict,
+ headlines_by_bucket: dict,
+ reference_line: str,
+) -> tuple[str, str]:
+ """System + user prompt for the Sunday weekly recap + look-ahead.
+
+ Sent to ALL opt-in users (free and paid). Target ~900 words."""
+ tone_clause = (
+ "Use plain English. Define any jargon on first use."
+ if tone.upper() == "NOVICE"
+ else "Write for a reader who already speaks markets fluently."
+ )
+ system = (
+ "You write the Sunday weekly digest for Read the Markets. "
+ f"Audience tone: {tone.upper()}. {tone_clause} "
+ "Cover: (1) the week behind — what moved and why, "
+ "(2) the week ahead — releases, earnings, central-bank meetings, "
+ "(3) the cross-asset story to keep in mind. "
+ "No predictions of price level, no buy/sell language. Target ~900 "
+ "words. Output HTML using only
,
,
,
, , "
+ " — no , , or wrapper, no inline styles."
+ )
+ user = _digest_user_prompt(today, quotes_by_group, headlines_by_bucket, reference_line)
+ return system, user
+
+
+def _digest_user_prompt(today, quotes_by_group, headlines_by_bucket, reference_line):
+ """Shared user-message body used by both digest prompts. Same data
+ shape as the hourly user prompt; reformatted for the digest context."""
+ today_str = today.strftime("%A %d %B %Y") if hasattr(today, "strftime") else str(today)
+ lines = [f"TODAY (UTC): {today_str}", "", f"REFERENCE: {reference_line}", ""]
+
+ if headlines_by_bucket:
+ lines.append("HEADLINES BY CATEGORY")
+ for cat, items in headlines_by_bucket.items():
+ lines.append(f" [{cat}]")
+ for h in items[:30]:
+ when = h.get("when", "")
+ src = h.get("source", "")
+ title = h.get("title", "")
+ lines.append(f" {when} · {src} · {title}")
+ lines.append("")
+
+ if quotes_by_group:
+ lines.append("LATEST QUOTES BY GROUP")
+ for grp, items in quotes_by_group.items():
+ lines.append(f" [{grp}]")
+ for q in items[:30]:
+ sym = q.get("symbol", "")
+ price = q.get("price", "")
+ lbl = q.get("label", "")
+ ccy = q.get("currency", "")
+ lines.append(f" {sym} ({lbl}) — {price} {ccy}")
+ lines.append("")
+
+ return "\n".join(lines)
+```
+
+Bump the PROMPT_VERSION constant at the top of the file. Locate it (search for `PROMPT_VERSION =`) and increment by one — e.g., `8` → `9`. Add a short comment line above noting the bump is for digest prompts.
+
+- [ ] **Step 5: Run the tests, expect PASS**
+
+```bash
+pytest tests/test_digest_prompts.py -v
+```
+
+Expected: 4 passed.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/services/openrouter.py tests/test_digest_prompts.py
+git commit -m "digest: daily + weekly prompt builders (NOVICE/INTERMEDIATE)"
+```
+
+---
+
+## Task 5: Digest email rendering
+
+**Files:**
+- Modify: `app/services/email_service.py`
+- Create: `tests/test_email_render.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/test_email_render.py`:
+
+```python
+"""Unit tests for render_digest_email."""
+from __future__ import annotations
+
+from app.services.email_service import render_digest_email
+
+
+def test_daily_subject_and_bodies():
+ subj, text, html = render_digest_email(
+ kind="daily",
+ date_str="2026-05-25",
+ content_html="
Markets did stuff today.
",
+ unsubscribe_url="https://read.markets/email/unsubscribe?token=abc",
+ settings_url="https://read.markets/settings",
+ )
+ assert "Daily" in subj
+ assert "2026-05-25" in subj
+ assert "Markets did stuff today" in html
+ assert "abc" in html # unsubscribe link landed
+ assert "/settings" in html
+ # Plain-text fallback strips HTML.
+ assert "
" not in text
+ assert "Markets did stuff today" in text
+
+
+def test_weekly_subject_says_recap():
+ subj, _, _ = render_digest_email(
+ kind="weekly",
+ date_str="2026-05-25",
+ content_html="