source: Klonkt/src/routes/newsletter.js@ f33b24a

main
Last change on this file since f33b24a was 2e247e4, checked in by roboburr <roboburr@…>, 3 months ago

Newsletter / mailing list (premium feature #1) + SMTP wiring

Email infrastructure reuses src/config/mailer.js (env SMTP_*, graceful: without
SMTP no sending, sign-ups still work via single opt-in).

  • DB: subscribers table (per-site, status pending/confirmed/unsub, token) + newsletters table (send history). Idempotent in initializeDatabase.
  • SubscriberService: addSubscriber (double opt-in if SMTP, otherwise single), confirm, unsubscribe, confirmedFor, counts. Email validation + dedupe per (site, email).
  • routes/newsletter.js (public, premium-gated; unsubscribe always works): GET/POST /nieuwsbrief, /nieuwsbrief/bevestigen/:token, /nieuwsbrief/uitschrijven/:token. Confirm/unsub links absolute via PUBLIC_BASE_URL + siteUrlBase.
  • routes/admin-newsletter.js (site admin + premium): GET /admin/newsletter (compose + counts + history + shareable signup link + SMTP warning), POST /admin/newsletter/send (to confirmed subscribers, unsubscribe link per email, history written).
  • views: pages/newsletter.ejs (multi-state) + pages/admin-newsletter.ejs.
  • admin dashboard: premium links Newsletter + Press kit added.

Feature #2 (download-for-email) and #8 (notify) reuse subscribers. node --check
passed; local test suite missing better-sqlite3 (runs on the VPS).

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 3.8 KB
Line 
1/**
2 * Nieuwsbrief — publieke kant (premium feature #1).
3 *
4 * GET /nieuwsbrief -> aanmeldformulier (premium; anders 404)
5 * POST /nieuwsbrief -> aanmelden (double opt-in als SMTP er is)
6 * GET /nieuwsbrief/bevestigen/:token -> opt-in bevestigen
7 * GET /nieuwsbrief/uitschrijven/:token -> uitschrijven (ALTIJD toegestaan)
8 *
9 * In hub-modus loopt dit via /user/:slug/nieuwsbrief (resolveSite zet siteUrlBase).
10 * Confirm-/unsub-links in de mail zijn absoluut (PUBLIC_BASE_URL + siteUrlBase).
11 */
12
13import express from 'express';
14import { renderPage } from '../middleware/render.js';
15import { premiumUnlocked } from '../services/PatreonService.js';
16import { mailerConfigured, sendMail } from '../config/mailer.js';
17import { addSubscriber, confirm, unsubscribe } from '../services/SubscriberService.js';
18
19const router = express.Router();
20
21function esc(s) {
22 return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
23}
24function fullUrl(req, siteUrlBase, p) {
25 const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
26 return base + (siteUrlBase || '') + p;
27}
28function show(req, res, state, extra = {}) {
29 renderPage(req, res, 'pages/newsletter', {
30 pageTitle: 'Nieuwsbrief' + (res.locals.site ? ' — ' + res.locals.site.title : ''),
31 bodyClass: 'on-newsletter',
32 nlState: state,
33 ...extra,
34 });
35}
36
37router.get('/nieuwsbrief', (req, res, next) => {
38 if (!premiumUnlocked()) return next();
39 if (!res.locals.site) return next();
40 const fan = req.session && req.session.user;
41 const prefill = (fan && fan.email && fan.email.includes('@')) ? fan.email : '';
42 show(req, res, 'form', { nlPrefill: prefill });
43});
44
45router.post('/nieuwsbrief', async (req, res, next) => {
46 if (!premiumUnlocked()) return next();
47 const site = res.locals.site;
48 if (!site) return next();
49 const email = (req.body.email || '').trim();
50 const doubleOptin = mailerConfigured();
51 const r = addSubscriber(site.id, email, 'widget', { doubleOptin });
52 if (!r.ok) return show(req, res, r.error === 'invalid_email' ? 'invalid' : 'error', { nlPrefill: email });
53
54 if (r.status === 'pending') {
55 // Double opt-in: stuur de bevestigingsmail.
56 const link = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/bevestigen/' + r.token);
57 const unsub = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/uitschrijven/' + r.token);
58 try {
59 await sendMail({
60 to: email,
61 subject: 'Bevestig je inschrijving — ' + (site.title || 'nieuwsbrief'),
62 text: 'Bevestig je inschrijving op de nieuwsbrief van ' + (site.title || '') + ':\n' + link +
63 '\n\nNiet aangevraagd? Negeer deze mail. Uitschrijven: ' + unsub,
64 html: '<p>Bevestig je inschrijving op de nieuwsbrief van <strong>' + esc(site.title) + '</strong>:</p>' +
65 '<p><a href="' + link + '">Inschrijving bevestigen</a></p>' +
66 '<p style="color:#888;font-size:12px">Niet aangevraagd? Negeer deze mail. ' +
67 '<a href="' + unsub + '">Uitschrijven</a></p>',
68 });
69 } catch (e) {
70 return show(req, res, 'smtperror');
71 }
72 return show(req, res, 'check', { nlEmail: email });
73 }
74 return show(req, res, 'done', { nlEmail: email });
75});
76
77router.get('/nieuwsbrief/bevestigen/:token', (req, res, next) => {
78 if (!premiumUnlocked()) return next();
79 const ok = confirm(req.params.token);
80 show(req, res, ok ? 'confirmed' : 'badtoken');
81});
82
83// Uitschrijven mag altijd (ook als de premium-laag later uit zou gaan): een abonnee
84// moet zich altijd kunnen afmelden. Niet premium-gated.
85router.get('/nieuwsbrief/uitschrijven/:token', (req, res) => {
86 const ok = unsubscribe(req.params.token);
87 show(req, res, ok ? 'unsubbed' : 'badtoken');
88});
89
90export default router;
Note: See TracBrowser for help on using the repository browser.