From 42062a2467e615bd5e311fdfe4ee51d910850bac Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 1 Mar 2026 11:23:17 -0800 Subject: [PATCH] feat: add Listmonk newsletter integration for VotC Add direct PostgreSQL Listmonk integration to sync waitlist signups and applications to the Valley of the Commons list (ID 24). Co-Authored-By: Claude Opus 4.6 --- api/application.js | 14 +++++++-- api/listmonk.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++ api/waitlist-db.js | 60 +++++++++++++++++++++++++---------- docker-compose.yml | 10 ++++++ 4 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 api/listmonk.js diff --git a/api/application.js b/api/application.js index 0ff0c90..e9678ed 100644 --- a/api/application.js +++ b/api/application.js @@ -5,6 +5,7 @@ const { Pool } = require('pg'); const nodemailer = require('nodemailer'); const { syncApplication } = require('./google-sheets'); const { createPayment, TICKET_LABELS, calculateAmount } = require('./mollie'); +const { addToListmonk } = require('./listmonk'); // Initialize PostgreSQL connection pool const pool = new Pool({ @@ -18,7 +19,7 @@ const smtp = nodemailer.createTransport({ port: parseInt(process.env.SMTP_PORT || '587'), secure: false, auth: { - user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com', + user: process.env.SMTP_USER || 'contact@valleyofthecommons.com', pass: process.env.SMTP_PASS || '', }, tls: { rejectUnauthorized: false }, @@ -295,12 +296,19 @@ module.exports = async function handler(req, res) { // Sync to Google Sheets (fire-and-forget backup) syncApplication(application); + // Add to Listmonk newsletter + addToListmonk(application.email, `${application.first_name} ${application.last_name}`, { + source: 'application', + weeks: weeksSelected, + contributionAmount: data.contribution_amount, + }).catch(err => console.error('[Listmonk] Application sync failed:', err.message)); + // Send confirmation email to applicant if (process.env.SMTP_PASS) { try { const confirmEmail = confirmationEmail(application); const info = await smtp.sendMail({ - from: process.env.EMAIL_FROM || 'Valley of the Commons ', + from: process.env.EMAIL_FROM || 'Valley of the Commons ', to: application.email, subject: confirmEmail.subject, html: confirmEmail.html, @@ -316,7 +324,7 @@ module.exports = async function handler(req, res) { const adminEmail = adminNotificationEmail(application); const adminRecipients = (process.env.ADMIN_EMAILS || 'jeff@jeffemmett.com').split(','); const info = await smtp.sendMail({ - from: process.env.EMAIL_FROM || 'Valley of the Commons ', + from: process.env.EMAIL_FROM || 'Valley of the Commons ', to: adminRecipients.join(', '), subject: adminEmail.subject, html: adminEmail.html, diff --git a/api/listmonk.js b/api/listmonk.js new file mode 100644 index 0000000..5ccce45 --- /dev/null +++ b/api/listmonk.js @@ -0,0 +1,78 @@ +// Listmonk newsletter integration via direct PostgreSQL access +const { Pool } = require('pg'); + +const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 24; // Valley of the Commons list + +const listmonkPool = process.env.LISTMONK_DB_HOST ? new Pool({ + host: process.env.LISTMONK_DB_HOST || 'listmonk-db', + port: parseInt(process.env.LISTMONK_DB_PORT) || 5432, + database: process.env.LISTMONK_DB_NAME || 'listmonk', + user: process.env.LISTMONK_DB_USER || 'listmonk', + password: process.env.LISTMONK_DB_PASS || '', +}) : null; + +async function addToListmonk(email, name, attribs = {}) { + if (!listmonkPool) { + console.log('[Listmonk] Database not configured, skipping'); + return false; + } + + const client = await listmonkPool.connect(); + try { + const mergeAttribs = { + votc: { + ...attribs, + registeredAt: new Date().toISOString(), + } + }; + + // Check if subscriber exists + const existing = await client.query( + 'SELECT id, attribs FROM subscribers WHERE email = $1', + [email] + ); + + let subscriberId; + + if (existing.rows.length > 0) { + subscriberId = existing.rows[0].id; + const existingAttribs = existing.rows[0].attribs || {}; + const merged = { ...existingAttribs, ...mergeAttribs }; + await client.query( + 'UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3', + [name, JSON.stringify(merged), subscriberId] + ); + console.log(`[Listmonk] Updated existing subscriber: ${email} (ID: ${subscriberId})`); + } else { + const result = await client.query( + `INSERT INTO subscribers (uuid, email, name, status, attribs, created_at, updated_at) + VALUES (gen_random_uuid(), $1, $2, 'enabled', $3, NOW(), NOW()) + RETURNING id`, + [email, name, JSON.stringify(mergeAttribs)] + ); + subscriberId = result.rows[0].id; + console.log(`[Listmonk] Created new subscriber: ${email} (ID: ${subscriberId})`); + } + + // Add to VotC list + await client.query( + `INSERT INTO subscriber_lists (subscriber_id, list_id, status, created_at, updated_at) + VALUES ($1, $2, 'confirmed', NOW(), NOW()) + ON CONFLICT (subscriber_id, list_id) DO UPDATE SET status = 'confirmed', updated_at = NOW()`, + [subscriberId, LISTMONK_LIST_ID] + ); + console.log(`[Listmonk] Added to VotC list: ${email}`); + return true; + } catch (error) { + console.error('[Listmonk] Error:', error.message); + return false; + } finally { + client.release(); + } +} + +function isConfigured() { + return !!listmonkPool; +} + +module.exports = { addToListmonk, isConfigured }; diff --git a/api/waitlist-db.js b/api/waitlist-db.js index 6e65433..6168188 100644 --- a/api/waitlist-db.js +++ b/api/waitlist-db.js @@ -4,6 +4,7 @@ const { Pool } = require('pg'); const nodemailer = require('nodemailer'); const { syncWaitlistSignup } = require('./google-sheets'); +const { addToListmonk } = require('./listmonk'); // Initialize PostgreSQL connection pool const pool = new Pool({ @@ -17,38 +18,57 @@ const smtp = nodemailer.createTransport({ port: parseInt(process.env.SMTP_PORT || '587'), secure: false, auth: { - user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com', + user: process.env.SMTP_USER || 'contact@valleyofthecommons.com', pass: process.env.SMTP_PASS || '', }, tls: { rejectUnauthorized: false }, }); const welcomeEmail = (signup) => ({ - subject: 'Welcome to Valley of the Commons', + subject: 'Welcome to the Valley — A Village Built on Common Ground', html: ` -
-

Welcome to the Valley!

+
+

Welcome to the Valley!

+

A village built on common ground

Dear ${signup.name},

-

Thank you for your interest in Valley of the Commons - a four-week pop-up village in the Austrian Alps (August 24 - September 20, 2026).

+

Thank you for stepping toward something different. Valley of the Commons is a four-week pop-up village in Austria's Höllental Valley (August 24 – September 20, 2026) — a living commons shared in work and study, in making and care, in governance and everyday life.

-

You've been added to our community list. We'll keep you updated on:

-
    -
  • Application opening and deadlines
  • -
  • Event announcements and updates
  • -
  • Ways to get involved
  • -
+

For four weeks, we'll come together to lay the foundations for life beyond extractive systems. Each week explores a different dimension of what a commons-based society can look like:

+ + + + + + + + + + + + + + + + + + +
Week 1Return of the Commons
Week 2Cosmo-local Production & Open Value Accounting
Week 3Future Living
Week 4Governance & Funding
+ +

Mornings are structured learning paths. Afternoons host workshops, field visits, and working groups. And in between — shared meals, hikes into the Alps, river swimming, mushroom foraging, fire circles, and the kind of conversations that only happen when people live and build together.

${signup.involvement ? ` -
- Your interests: -

${signup.involvement}

+
+ What you're bringing: +

${signup.involvement}

` : ''} -

- +

We'll be in touch with application details, event updates, and ways to get involved as the village takes shape.

+ +

+ Apply Now

@@ -149,12 +169,18 @@ module.exports = async function handler(req, res) { // Sync to Google Sheets (fire-and-forget backup) syncWaitlistSignup(signup); + // Add to Listmonk newsletter + addToListmonk(signup.email, signup.name, { + involvement: signup.involvement, + source: 'waitlist', + }).catch(err => console.error('[Listmonk] Waitlist sync failed:', err.message)); + // Send welcome email if (process.env.SMTP_PASS) { try { const email = welcomeEmail(signup); const info = await smtp.sendMail({ - from: process.env.EMAIL_FROM || 'Valley of the Commons ', + from: process.env.EMAIL_FROM || 'Valley of the Commons ', to: signup.email, subject: email.subject, html: email.html, diff --git a/docker-compose.yml b/docker-compose.yml index b00c017..925e3a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,12 @@ services: - INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID} - INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET} - INFISICAL_PROJECT_SLUG=valley-commons + - LISTMONK_DB_HOST=listmonk-db + - LISTMONK_DB_PORT=5432 + - LISTMONK_DB_NAME=listmonk + - LISTMONK_DB_USER=listmonk + - LISTMONK_DB_PASS=${LISTMONK_DB_PASS:-listmonk_secure_2025} + - LISTMONK_LIST_ID=24 depends_on: votc-db: condition: service_healthy @@ -16,6 +22,7 @@ services: - "traefik.http.services.votc.loadbalancer.server.port=3000" networks: - traefik-public + - listmonk-internal votc-db: image: postgres:16-alpine @@ -42,3 +49,6 @@ volumes: networks: traefik-public: external: true + listmonk-internal: + external: + name: listmonk_listmonk-internal