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>
This commit is contained in:
@@ -801,6 +801,35 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
|
||||
amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and
|
||||
schedule-derived waiting copy. Keep them green.
|
||||
|
||||
## Newsletter — THE VYNDR REPORT (Session S7, a1 — non-obvious)
|
||||
- **`newsletterService`** is the whole layer: `buildDailyReport(sports, deps)`
|
||||
(deterministic VOICE v1.1 template over snapshot grades + streak lens +
|
||||
`getModelAggregate`; every dep injectable), `subscribe(email)` and
|
||||
`sendDailyReport(opts)` (Listmonk, self-hosted, zero out-of-pocket). ALL
|
||||
Listmonk paths are env-gated on `LISTMONK_URL/USER/TOKEN/LIST_ID` — any
|
||||
missing → `{ ok:false, reason:'not configured' }`, and the capture UI shows
|
||||
"Signups open soon". Auth is the API-user scheme `Authorization: token
|
||||
user:token`, NOT basic auth.
|
||||
- **Double opt-in is Listmonk's job:** subscribe sends
|
||||
`preconfirm_subscriptions:false`; the list itself must be created as
|
||||
double-opt-in (runbook: `docs/NEWSLETTER.md`). The email template embeds the
|
||||
LITERAL `{{ UnsubscribeURL }}` — Listmonk substitutes per-recipient; never
|
||||
"fix" it. A 409 (already subscribed) is returned as ok — no enumeration.
|
||||
- **The send is deliberately unscheduled.** Only `POST
|
||||
/api/internal/newsletter/send` (internal key) triggers it; an empty report
|
||||
(0 signals AND 0 streaks) refuses to send. The record line renders a
|
||||
percentage ONLY when `hit_pct != null` (n≥20 gate upstream in
|
||||
`getModelAggregate`), else "RECORD BUILDING · N pending".
|
||||
- **VOICE lint is executable:** `tests/unit/newsletterService.test.js` fails on
|
||||
any `!`, banned vocabulary (word-boundary regex), or hype emoji in
|
||||
subject/html/text. The RG disclaimer says "No outcome is promised" — the word
|
||||
"guarantee" trips the lint even in a disclaimer, keep it out.
|
||||
- **Capture placement:** the signup "done" card auto-redirects after 1.5s
|
||||
(`setTimeout(router.replace)`) — useless for a form. The free-signup success
|
||||
surface is `/welcome`. Landing capture sits below FAQ. Subscribe route is
|
||||
10/min IP-limited → keep `tests/integration/newsletterRoute.test.js` under 10
|
||||
subscribe requests (same lesson as analyze.test.js).
|
||||
|
||||
## Active Skills
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
Reference in New Issue
Block a user