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:
@@ -214,6 +214,32 @@ router.post('/ledger/settle', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/internal/newsletter/send (Session S7, a1) — assemble today's
|
||||
* VYNDR REPORT from the pipeline (snapshot signals + streak lens + the
|
||||
* ledger record) and send it as a Listmonk campaign to the opted-in list.
|
||||
*
|
||||
* DELIBERATELY UNSCHEDULED: nothing calls this on a timer. The operator
|
||||
* (or a future n8n cron, once Kev arms it) triggers the send. Env-gated —
|
||||
* without LISTMONK_* config it's a calm no-op; an empty report (zero
|
||||
* signals AND zero streaks) refuses to send.
|
||||
*
|
||||
* Body (optional): { sports?: string[] } — defaults to ['mlb', 'wnba'].
|
||||
*/
|
||||
router.post('/newsletter/send', async (req, res) => {
|
||||
const newsletter = require('../services/newsletterService');
|
||||
const body = (req.body && typeof req.body === 'object') ? req.body : {};
|
||||
const sports = Array.isArray(body.sports) && body.sports.length > 0 ? body.sports : undefined;
|
||||
try {
|
||||
const result = await newsletter.sendDailyReport({ sports });
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
const message = err && err.message ? err.message : String(err);
|
||||
console.error('[internal/newsletter/send] failed:', message);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/outcomes/:sport', async (req, res) => {
|
||||
const outcomes = require('../services/outcomeService');
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* THE VYNDR REPORT — public subscribe endpoint (Session S7, a1 board).
|
||||
*
|
||||
* POST /api/newsletter/subscribe { email, website? }
|
||||
*
|
||||
* Forwards to the self-hosted Listmonk's subscriber API with double opt-in
|
||||
* (preconfirm_subscriptions: false → Listmonk sends the confirmation email;
|
||||
* nothing lands in the list until the visitor clicks it).
|
||||
*
|
||||
* Grace notes:
|
||||
* - Without LISTMONK_* env this is a calm HTTP 200
|
||||
* { ok: false, reason: 'not configured' } — the UI renders
|
||||
* "signups open soon", never a scary error.
|
||||
* - `website` is a honeypot (same trick as /api/waitlist): bots fill the
|
||||
* hidden field, humans don't. Filled → silent { ok: true }.
|
||||
* - Listmonk 409 (already subscribed) is { ok: true } — idempotent, and the
|
||||
* response never reveals whether an address exists.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
const newsletterService = require('../services/newsletterService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Public + writes upstream → tight throttle (10/min per IP).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 10 }));
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
router.post('/subscribe', async (req, res) => {
|
||||
const body = (req.body && typeof req.body === 'object') ? req.body : {};
|
||||
|
||||
// Honeypot — silently accept and drop.
|
||||
if (body.website) return res.json({ ok: true });
|
||||
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
||||
if (!email || email.length > 254 || !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ ok: false, error: 'Enter a valid email.' });
|
||||
}
|
||||
|
||||
const result = await newsletterService.subscribe(email);
|
||||
if (result.ok) return res.json({ ok: true });
|
||||
if (result.reason === 'not configured') {
|
||||
return res.json({ ok: false, reason: 'not configured' });
|
||||
}
|
||||
// Upstream hiccup — log it, keep the visitor's response calm.
|
||||
console.error('[newsletter/subscribe] listmonk error:', result.reason);
|
||||
return res.json({ ok: false, reason: 'unavailable' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user