source: Klonkt/src/services/SubscriberService.js@ 37edecd

main
Last change on this file since 37edecd 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.4 KB
Line 
1/**
2 * SubscriberService — nieuwsbrief-abonnees per site (premium feature #1).
3 *
4 * Double opt-in als SMTP er is (status 'pending' → 'confirmed' via confirm-link),
5 * anders single opt-in ('confirmed' meteen). Elke abonnee heeft een token dat zowel
6 * de confirm- als de unsubscribe-link draagt. Hergebruikt door #2 (download-voor-
7 * email) en #8 (notify-me) als gedeelde abonnee-opslag.
8 */
9
10import crypto from 'crypto';
11import db from '../config/database.js';
12import { v4 as uuid } from 'uuid';
13
14const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
15
16export function isValidEmail(email) {
17 return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email.trim());
18}
19
20function newToken() {
21 return crypto.randomBytes(24).toString('hex');
22}
23
24/**
25 * Voeg een abonnee toe (of heractiveer een uitgeschreven/bestaande).
26 * @returns {{ok:boolean, status?:string, token?:string, created?:boolean, error?:string}}
27 * status 'pending' → er moet nog bevestigd worden (stuur confirm-mail)
28 * status 'confirmed'→ direct actief (single opt-in)
29 */
30export function addSubscriber(siteId, email, source = 'widget', { doubleOptin = false } = {}) {
31 email = (email || '').trim().toLowerCase();
32 if (!siteId) return { ok: false, error: 'no_site' };
33 if (!isValidEmail(email)) return { ok: false, error: 'invalid_email' };
34
35 const existing = db.prepare('SELECT * FROM subscribers WHERE site_id = ? AND email = ?').get(siteId, email);
36 const status = doubleOptin ? 'pending' : 'confirmed';
37
38 if (existing) {
39 // Al actief → niets te doen (idempotent, geen dubbele mail).
40 if (existing.status === 'confirmed') return { ok: true, status: 'confirmed', token: existing.token, created: false };
41 // Pending of uitgeschreven → opnieuw uitnodigen/activeren met een verse token.
42 const token = newToken();
43 db.prepare("UPDATE subscribers SET status = ?, token = ?, source = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END WHERE id = ?")
44 .run(status, token, source, status, existing.id);
45 return { ok: true, status, token, created: false };
46 }
47
48 const token = newToken();
49 db.prepare(
50 "INSERT INTO subscribers (id, site_id, email, status, source, token, confirmed_at) VALUES (?,?,?,?,?,?, CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END)"
51 ).run(uuid(), siteId, email, status, source, token, status);
52 return { ok: true, status, token, created: true };
53}
54
55export function confirm(token) {
56 if (!token) return false;
57 const row = db.prepare('SELECT id FROM subscribers WHERE token = ?').get(token);
58 if (!row) return false;
59 db.prepare("UPDATE subscribers SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?").run(row.id);
60 return true;
61}
62
63export function unsubscribe(token) {
64 if (!token) return false;
65 const row = db.prepare('SELECT id FROM subscribers WHERE token = ?').get(token);
66 if (!row) return false;
67 db.prepare("UPDATE subscribers SET status = 'unsub' WHERE id = ?").run(row.id);
68 return true;
69}
70
71/** Bevestigde abonnees (email + token) voor een site — voor het versturen. */
72export function confirmedFor(siteId) {
73 return db.prepare("SELECT email, token FROM subscribers WHERE site_id = ? AND status = 'confirmed'").all(siteId);
74}
75
76export function counts(siteId) {
77 const c = (st) => db.prepare('SELECT COUNT(*) AS n FROM subscribers WHERE site_id = ? AND status = ?').get(siteId, st).n;
78 return { confirmed: c('confirmed'), pending: c('pending'), unsub: c('unsub') };
79}
Note: See TracBrowser for help on using the repository browser.