32d7200571
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>
85 lines
4.6 KiB
Markdown
85 lines
4.6 KiB
Markdown
# 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).
|