# Feature A1-S7 — Newsletter: THE VYNDR REPORT ## Goal A daily email — the slate, the signals, the settle — for opted-in subscribers. Zero out-of-pocket: Listmonk is self-hosted on the same Hetzner box. VOICE v1.1 governs every line. Nothing auto-posts; the send is operator-triggered through an internal route until Kev arms a schedule. ## Endpoints ### POST /api/newsletter/subscribe (public, rate-limited 10/min per IP) Body: `{ email: string, website?: string }` (`website` = honeypot; bots filled → silent `{ ok: true }`). - Valid email + Listmonk env set → forward to Listmonk subscriber API with `preconfirm_subscriptions: false` (Listmonk sends the double-opt-in confirmation). Response: `{ ok: true }` (also on Listmonk 409 = already subscribed — idempotent, no enumeration). - Env missing → HTTP 200 `{ ok: false, reason: 'not configured' }` (the UI says "signups open soon" — never a scary error). - Invalid email → 400 `{ ok: false, error: 'Enter a valid email.' }`. Env: `LISTMONK_URL`, `LISTMONK_USER`, `LISTMONK_TOKEN`, `LISTMONK_LIST_ID`. Auth header: `Authorization: token USER:TOKEN` (Listmonk API-user scheme). ### POST /api/internal/newsletter/send (requireInternalAuth) Body: `{ sports?: string[] }` (default `['mlb', 'wnba']`). Assembles the daily report and creates + starts a Listmonk campaign. Returns the assembly summary. Refuses (`ok: false, reason: 'empty report'`) when there are zero signals AND zero streaks — an empty email never sends. NOT scheduled anywhere; the operator (or a future n8n cron) calls this. ### Next proxy: web/src/app/api/newsletter/subscribe/route.ts S25 rule — forwards POST to `${BACKEND_URL}/api/newsletter/subscribe`. On upstream failure returns 200 `{ ok: false, reason: 'not configured' }` so the visitor sees the graceful "soon" state. ## Data shapes `newsletterService.buildDailyReport(sports, deps)` → `{ subject, html, text, counts: { signals, streaks, sports } }` - Signals: `cacheGet('snapshot:{sport}:latest')` → grades, top 5 per sport by confidence (A-tier first). Numbers only from the pipeline. - STREAK WATCH: `loadRosterLogs` → `streaksService.computeStreaks` → `streakLens.applyLens` (lens ctx = cached schedule only; absent = say less). Top 3 across sports by streak length. - THE RECORD: `ledgerService.getModelAggregate()` — render the record line ONLY when `hit_pct != null` (the n≥20 gate lives in the aggregate); otherwise `RECORD BUILDING · N pending`. - All deps injectable; unit tests use fixtures, no network, no Redis. `sendDailyReport(opts)` — env-gated (`ok:false, reason:'not configured'` without Listmonk env), POSTs `/api/campaigns` then PUT status `running`. `fetchImpl` injectable. ## VOICE / compliance invariants (asserted by tests) - No `!` anywhere in subject, html, or text. - Banned vocabulary never appears: "lock", "tail"/"fade" as CTAs, hype emoji. - Record percentage renders only under the n≥20 gate (hit_pct null → building line). - RG footer in both html + text: 21+, 1-800-GAMBLER, unsubscribe note. - One-click unsub is Listmonk-native: literal `{{ UnsubscribeURL }}` placeholder in the template (Listmonk substitutes at send time). ## Frontend `web/src/components/NewsletterCapture.tsx` — dark-terminal styled, mono data font. Copy: "THE VYNDR REPORT — the slate, the signals, the settle. Daily. Free." Double-opt-in note under the field. States: idle / busy / sent ("Confirmation sent. Check your inbox.") / soon ("Signups open soon."). Mounted on the landing page (after FAQ, near the footer) and on the signup success card (the free-signup success path). ## Acceptance criteria 1. POST /api/newsletter/subscribe with no env → 200 `{ ok:false, reason:'not configured' }`. 2. With env (mocked fetch) → Listmonk called with `preconfirm_subscriptions:false`, list id from env, `token` auth header; 409 treated as ok. 3. Invalid email → 400; honeypot → silent ok. 4. buildDailyReport output passes the VOICE lint (no `!`, no banned words) and includes the RG footer + `{{ UnsubscribeURL }}`. 5. Record line gated: hit_pct null → "RECORD BUILDING · N pending"; hit_pct present → the percentage renders. 6. sendDailyReport without env = no-op; empty report never sends. 7. Full `npx jest` green; `cd web && npx next build` exit 0. ## Test plan - `tests/unit/newsletterService.test.js` — fixtures for snapshot grades, roster logs, aggregate; VOICE lint; gates; RG footer; send no-op + campaign create/start via injected fetch; empty-report refusal. - `tests/integration/newsletterRoute.test.js` — supertest against app with mocked global fetch + redis mock; ≤10 requests total (the 10/min limiter has no reset hook — same lesson as analyze.test.js).