Files
vyndr/docs/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

THE VYNDR REPORT — Listmonk runbook (Session S7, a1)

The daily newsletter: the slate, the signals, the settle. Assembly is code (src/services/newsletterService.js — numbers only from the pipeline, VOICE v1.1, RG footer baked in). Delivery is Listmonk, self-hosted on the same Hetzner box — zero out-of-pocket. Without the env vars below, every newsletter path is a graceful no-op: the subscribe endpoint answers { ok: false, reason: 'not configured' } and the capture UI says "Signups open soon."

Nothing is scheduled. The send is operator-triggered via the internal route until Kev arms a cron.

1. Deploy Listmonk on the box (Coolify)

Listmonk is a single Go binary + Postgres. In Coolify: new service → Docker image listmonk/listmonk:latest + a Postgres 16 resource (or reuse an existing Postgres with a fresh listmonk database).

Minimum container env:

LISTMONK_app__address=0.0.0.0:9000
LISTMONK_db__host=<postgres host>
LISTMONK_db__port=5432
LISTMONK_db__user=listmonk
LISTMONK_db__password=<generate>
LISTMONK_db__database=listmonk

First boot needs the schema install: run ./listmonk --install once (Coolify: one-off command on the container), then start normally. Log in at the service URL, change the default listmonk admin password immediately.

Configure SMTP under Settings → SMTP (any relay you already have; without a working SMTP block the double-opt-in confirmations never leave the box — test this first).

2. Create the list

Admin UI → Lists → New:

  • Name: THE VYNDR REPORT
  • Type: private
  • Opt-in: double ← required. The API subscribe call sends preconfirm_subscriptions: false; Listmonk then emails the confirmation. Nothing lands on the list until the reader clicks it.

Note the numeric list id shown in the UI/URL — that is LISTMONK_LIST_ID.

3. Create an API user (never use the admin login for the app)

Admin UI → Settings → Users → New:

  • Username: vyndr-api
  • Type: API user → Listmonk generates a token. Copy it once; it is not shown again.
  • Role: needs subscribers:manage + campaigns:manage + campaigns:get (or a role scoped to exactly those).

The app authenticates with Authorization: token vyndr-api:<token>.

4. Env vars for the VYNDR backend (Coolify → the Express app)

LISTMONK_URL=http://<listmonk container name>:9000   # same box — use the internal network, not the public URL
LISTMONK_USER=vyndr-api
LISTMONK_TOKEN=<the API token from step 3>
LISTMONK_LIST_ID=<numeric id from step 2>

All four are required; any missing → the whole layer no-ops (deploy-safe).

5. Template note — one-click unsubscribe

The assembled email embeds the literal {{ UnsubscribeURL }} placeholder in its RG footer. Listmonk substitutes it per-recipient at send time — do not "fix" it in the service. The RG footer (21+, 1-800-GAMBLER, unsubscribe note) is baked into newsletterService and asserted by tests/unit/newsletterService.test.js; every send carries it.

6. Test-send steps

  1. Subscribe yourself through the product: the landing-page capture (or curl -X POST https://vyndr.app/api/newsletter/subscribe -H 'Content-Type: application/json' -d '{"email":"kevdevelops@gmail.com"}').
  2. Confirm the double-opt-in email arrives and click it — you should show as confirmed on the list in the Listmonk UI.
  3. Fire a real send (internal key, operator-triggered):
curl -X POST https://<backend>/api/internal/newsletter/send \
  -H "x-internal-key: $VYNDR_INTERNAL_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"sports":["mlb","wnba"]}'

Responses:

  • { ok: true, campaignId, subject, counts } — sent; check the inbox + Listmonk → Campaigns for delivery stats.
  • { ok: false, reason: 'empty report' } — zero signals AND zero streaks at send time (off-hours / cold caches). Deliberate: an empty email never sends. Run after the morning snapshot (14:00 UTC) so the slate is graded.
  • { ok: false, reason: 'not configured' } — env missing on the backend.
  1. Verify the received email: mono layout, signals match /api/snapshot/mlb, the record line matches /api/ledger/accuracy gating (a percentage only at n≥20, otherwise RECORD BUILDING · N pending), RG footer present, and the unsubscribe link is a working per-recipient Listmonk URL.

7. Scheduling (NOT armed)

When Kev decides to arm the daily send, point an n8n cron (or the box crontab) at the internal route above — e.g. daily at 15:30 UTC, after the 14:00 UTC snapshot has graded the slate. There is deliberately no in-process scheduler for this; email to subscribers stays operator-controlled until then.