# 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= LISTMONK_db__port=5432 LISTMONK_db__user=listmonk LISTMONK_db__password= 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:`. ## 4. Env vars for the VYNDR backend (Coolify → the Express app) ``` LISTMONK_URL=http://:9000 # same box — use the internal network, not the public URL LISTMONK_USER=vyndr-api LISTMONK_TOKEN= LISTMONK_LIST_ID= ``` 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:///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. 4. 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.