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>
113 lines
4.6 KiB
Markdown
113 lines
4.6 KiB
Markdown
# 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.
|
|
|
|
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.
|