Changeset 2e247e4 in Klonkt


Ignore:
Timestamp:
06/19/2026 12:00:48 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
91094a4
Parents:
255e3d3
Message:

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@…>

Location:
src
Files:
5 added
3 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r255e3d3 r2e247e4  
    188188  // Tags van de originele post — getoond in de cirkel (comma-separated string).
    189189  ensureColumn('remote_posts', 'tags', 'TEXT');
     190
     191  // Nieuwsbrief / mailinglijst (premium). Abonnees per site; double opt-in als SMTP
     192  // er is (status 'pending' tot bevestigd), anders single opt-in ('confirmed').
     193  // 'unsub' = uitgeschreven. token = confirm/unsubscribe-sleutel (in de e-maillinks).
     194  db.exec(`
     195    CREATE TABLE IF NOT EXISTS subscribers (
     196      id TEXT PRIMARY KEY,
     197      site_id TEXT NOT NULL,
     198      email TEXT NOT NULL,
     199      status TEXT NOT NULL DEFAULT 'pending',
     200      source TEXT DEFAULT 'widget',
     201      token TEXT NOT NULL,
     202      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     203      confirmed_at DATETIME,
     204      UNIQUE(site_id, email)
     205    );
     206    CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
     207  `);
     208
     209  // Verstuurde nieuwsbrieven (historie + aantallen).
     210  db.exec(`
     211    CREATE TABLE IF NOT EXISTS newsletters (
     212      id TEXT PRIMARY KEY,
     213      site_id TEXT NOT NULL,
     214      subject TEXT NOT NULL,
     215      body TEXT NOT NULL,
     216      sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     217      recipient_count INTEGER DEFAULT 0
     218    );
     219  `);
    190220}
    191221
  • src/server.js

    r255e3d3 r2e247e4  
    5353import circleRoutes from './routes/circle.js';
    5454import epkRoutes from './routes/epk.js';
     55import newsletterRoutes from './routes/newsletter.js';
     56import adminNewsletterRoutes from './routes/admin-newsletter.js';
    5557
    5658if (!process.env.SESSION_SECRET) {
     
    252254app.use('/admin/patreon', adminPatreonRoutes);
    253255app.use('/admin/stats', adminStatsRoutes);
     256app.use('/admin/newsletter', adminNewsletterRoutes);
    254257app.use('/admin', adminRoutes);
    255258app.use('/prutter', prutterRoutes);
     
    267270app.use('/', circleRoutes); // /cirkel-feed (solo/hub: next() -> postsRoutes)
    268271app.use('/', epkRoutes); // /pers perskit (premium; niet-premium: next() -> 404)
     272app.use('/', newsletterRoutes); // /nieuwsbrief in/uitschrijven (premium; niet-premium: next())
    269273app.use('/', postsRoutes);
    270274
  • src/views/pages/admin.ejs

    r255e3d3 r2e247e4  
    3333    <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    3434      <a href="/admin/stats" class="btn">📊 Statistieken</a>
     35      <a href="/admin/newsletter" class="btn">✉️ Nieuwsbrief</a>
     36      <% if (tenancy !== 'hub' && primarySite) { %><a href="/pers" class="btn" target="_blank">📰 Perskit</a><% } %>
    3537    <% } %>
    3638    <a href="/admin/updates" class="btn">🔄 Updates</a>
Note: See TracChangeset for help on using the changeset viewer.