Files
vyndr/specs/feature-a1-s7-newsletter.md
builtbykev 32d7200571 S7 (a1): newsletter — THE VYNDR REPORT
Email capture + daily report assembly + operator-triggered Listmonk send.

- NewsletterCapture (dark terminal, mono) on landing (below FAQ) + /welcome
  (the real signup success surface); double-opt-in note; 'Signups open soon'
  when Listmonk env is unset.
- POST /api/newsletter/subscribe: public, 10/min IP limit, honeypot,
  server-side email validation, forwards to Listmonk subscribers API with
  preconfirm_subscriptions:false (Listmonk sends the confirmation).
  No env -> calm 200 { ok:false, reason:'not configured' }. Next proxy
  web/src/app/api/newsletter/subscribe/route.ts (S25 rule).
- newsletterService.buildDailyReport: signals from snapshot:{sport}:latest,
  STREAK WATCH via rosterLogs -> streaksService -> streakLens, THE RECORD via
  ledgerService.getModelAggregate (percentage only when hit_pct != null —
  n>=20 gate — else 'RECORD BUILDING · N pending'). RG footer (21+,
  1-800-GAMBLER, Listmonk-native {{ UnsubscribeURL }}) in html + text.
  VOICE v1.1 lint locked by tests: no '!', no banned vocabulary, numbers
  only from injected pipeline data.
- sendDailyReport: creates + starts a Listmonk campaign; env-gated no-op;
  refuses an empty report. Deliberately UNSCHEDULED — only
  POST /api/internal/newsletter/send (internal key) triggers it.
- docs/NEWSLETTER.md: box-side Listmonk runbook (install, double-opt-in
  list, API user, Coolify env, test-send).
- Spec: specs/feature-a1-s7-newsletter.md.

Tests 2398 -> 2429 (207 suites, all green); next build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:29:22 -04:00

4.6 KiB

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: loadRosterLogsstreaksService.computeStreaksstreakLens.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).